Skip to main content

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}