majordomus_cli/model.rs
1//! The domain model: what an object is, where it came from, and what was wrong with what
2//! could not become one.
3//!
4//! Nothing here knows about files being read or protocols being spoken. Discovery produces
5//! these types, the index holds them, and every projection consumes them — which is what
6//! lets the same [`Object`] be an MCP resource, an HTTP response, a row of the generated
7//! reference and a page of the site without any of those knowing how it was read.
8//!
9//! # Diagnostics are not errors
10//!
11//! A malformed file inside the layer is a [`Diagnostic`], never an [`crate::Error`]. The
12//! distinction is load-bearing: an error stops the process, and one bad file must not.
13//! [`Severity::Error`] excludes that file from the index and puts the index into the
14//! degraded state, so the repository still answers and says what it could not read;
15//! `Warning` and `Info` do neither. Every diagnostic carries a stable code, so a gate can
16//! act on one without matching prose.
17//!
18//! ```
19//! use majordomus_cli::{Diagnostic, Severity};
20//! use majordomus_cli::model::uri_for;
21//!
22//! // what could not be read, said in a way something can act on
23//! let bad = Diagnostic::error(
24//! "front_matter",
25//! Some(".ai/repo/rules/project/broken.v1.md".into()),
26//! "front matter opened on line 1 and never closed",
27//! );
28//! assert_eq!(bad.severity, Severity::Error);
29//! assert_eq!(bad.code, "front_matter");
30//!
31//! // and a warning leaves the file in the index
32//! assert_eq!(Diagnostic::warning("tags", None, "no tags").severity, Severity::Warning);
33//!
34//! // an object is addressed the same way wherever it is read
35//! assert_eq!(uri_for("rule", "project.scope-is-declared@1"),
36//! "majordomus://rule/project.scope-is-declared@1");
37//! ```
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42
43/// How bad a diagnostic is. `Error` excludes the file it concerns from the index and puts
44/// the index into the degraded state; `Warning` and `Info` do neither.
45#[derive(
46 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
47)]
48#[serde(rename_all = "lowercase")]
49pub enum Severity {
50 /// Worth knowing; changes nothing.
51 Info,
52 /// Something to look at; the object is still served.
53 Warning,
54 /// The file is excluded and the index is degraded.
55 Error,
56}
57
58impl Severity {
59 /// The word as serialised.
60 ///
61 /// ```
62 /// use majordomus_cli::Severity;
63 /// assert_eq!(Severity::Warning.as_str(), "warning");
64 /// ```
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Severity::Info => "info",
68 Severity::Warning => "warning",
69 Severity::Error => "error",
70 }
71 }
72}
73
74/// One finding about the declarative state, named by a stable code, tied to a path where
75/// there is one, and carrying the command that reproduces it where there is one.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77pub struct Diagnostic {
78 /// How bad.
79 pub severity: Severity,
80 /// A stable machine-readable code, e.g. `unknown_key`, `duplicate_identity`.
81 pub code: String,
82 /// Repository-relative path of the file concerned, when there is one.
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub path: Option<String>,
85 /// What is wrong, for a person; the code is for a program.
86 pub message: String,
87}
88
89impl Diagnostic {
90 /// An error diagnostic.
91 ///
92 /// ```
93 /// use majordomus_cli::{Diagnostic, Severity};
94 /// let d = Diagnostic::error("unknown_key", Some("docs/x.md".into()), "key(s) not in schema 'rule': owner");
95 /// assert_eq!(d.severity, Severity::Error);
96 /// assert_eq!(d.code, "unknown_key");
97 /// ```
98 pub fn error(code: &'static str, path: Option<String>, message: impl Into<String>) -> Self {
99 Diagnostic {
100 severity: Severity::Error,
101 code: code.into(),
102 path,
103 message: message.into(),
104 }
105 }
106 /// A warning diagnostic.
107 pub fn warning(code: &'static str, path: Option<String>, message: impl Into<String>) -> Self {
108 Diagnostic {
109 severity: Severity::Warning,
110 code: code.into(),
111 path,
112 message: message.into(),
113 }
114 }
115 /// An informational diagnostic.
116 pub fn info(code: &'static str, path: Option<String>, message: impl Into<String>) -> Self {
117 Diagnostic {
118 severity: Severity::Info,
119 code: code.into(),
120 path,
121 message: message.into(),
122 }
123 }
124}
125
126/// Where an object came from. Every field is computed from the repository, never authored.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[schemars(rename = "ObjectProvenance")]
129pub struct Provenance {
130 /// Repository-relative path, forward slashes, as the version-control index names it.
131 pub path: String,
132 /// The directory the path sits in, repository-relative; `.` for the root. This is the
133 /// hierarchy position a client orders by; no merge semantics are implied.
134 pub directory: String,
135 /// The `sources.yaml` class that discovered the file (`rule`, `readme`, ...).
136 pub source_class: String,
137 /// The manifest section the path falls under (`rules`, `prompts`, ...), when it falls
138 /// under one; a root `README.md` falls under none.
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub section: Option<String>,
141 /// Size of the file in bytes.
142 pub bytes: u64,
143 /// For an object that is one member of a collection file: the member's key path in
144 /// the file (`claims.3`).
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub member: Option<String>,
147}
148
149/// One declarative object of the layer: a rule, a prompt, a profile, a document.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151pub struct Object {
152 /// The kind the repository declared for the class that discovered it.
153 pub kind: String,
154 /// Stable identity within the kind: the identity fields joined with `@`, or the path.
155 pub identity: String,
156 /// `majordomus://<kind>/<identity>`; unique across the index.
157 pub uri: String,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 /// The title, when the kind's title rule found one.
160 pub title: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 /// The one-line description, when the kind holds one.
163 pub description: Option<String>,
164 /// The parsed metadata: the front matter of a Markdown file, or the whole of a YAML
165 /// file. Key order is the file's.
166 pub metadata: Value,
167 /// The Markdown body without its front matter, or the raw text of a YAML file.
168 #[serde(skip)]
169 pub body: String,
170 /// The whole file as read.
171 #[serde(skip)]
172 pub content: String,
173 /// IANA media type of `content`.
174 pub media_type: &'static str,
175 /// Where it came from.
176 pub provenance: Provenance,
177}
178
179impl Object {
180 /// The tags an object declares, when its metadata carries a `tags` list.
181 pub fn tags(&self) -> Vec<&str> {
182 self.metadata
183 .get("tags")
184 .and_then(Value::as_array)
185 .map(|a| a.iter().filter_map(Value::as_str).collect())
186 .unwrap_or_default()
187 }
188}
189
190/// Build the canonical URI for a kind and identity.
191pub fn uri_for(kind: &str, identity: &str) -> String {
192 format!("majordomus://{kind}/{identity}")
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn diagnostics_carry_their_severity_and_uri_is_canonical() {
201 assert_eq!(
202 Diagnostic::warning("claimed_twice", None, "x").severity,
203 Severity::Warning
204 );
205 assert_eq!(
206 Diagnostic::info("note", Some("a".into()), "x").severity,
207 Severity::Info
208 );
209 assert!(Severity::Error > Severity::Warning && Severity::Warning > Severity::Info);
210 assert_eq!(
211 uri_for("rule", "project.x@1"),
212 "majordomus://rule/project.x@1"
213 );
214 }
215}