Skip to main content

graphforge_core/
manifest.rs

1//! Project manifest (`graphforge.yaml`) — the entry point that describes a
2//! GraphForge project directory: its identity, ontology mode, and which
3//! capabilities are enabled.
4//!
5//! [`GraphForge::new`](crate::GraphForge) opens a project directory and reads
6//! this manifest to determine how to construct the engine.  The format is
7//! intentionally forward-compatible: unknown fields are ignored (no
8//! `deny_unknown_fields`) so newer manifests still load on older binaries.
9
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::{GfError, OntologyMode};
16
17/// File name of the manifest within a project directory.
18pub const MANIFEST_FILE: &str = "graphforge.yaml";
19/// File name of the optional ontology definition within a project directory.
20pub const ONTOLOGY_FILE: &str = "ontology.yaml";
21
22/// Feature flags recording which capabilities a project uses.
23///
24/// All fields default to `false` (via `#[serde(default)]`) so a manifest with
25/// no `capabilities:` block deserialises cleanly.  [`topology`](Self::topology)
26/// and [`properties`](Self::properties) are always enabled in practice — see
27/// [`ProjectManifest::enabled_capabilities`].
28//
29// This is a serialised manifest schema, not a state machine — each flag is an
30// independent on-disk capability toggle, so the "too many bools" lint does not
31// apply (the layout mirrors the `capabilities:` YAML block 1:1).
32#[allow(clippy::struct_excessive_bools)]
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Capabilities {
35    /// Node/edge topology storage (always on).
36    #[serde(default)]
37    pub topology: bool,
38    /// Per-type property storage (always on).
39    #[serde(default)]
40    pub properties: bool,
41    /// Document attachments.
42    #[serde(default)]
43    pub documents: bool,
44    /// Confidence + lineage provenance.
45    #[serde(default)]
46    pub provenance: bool,
47    /// Vector embeddings.
48    #[serde(default)]
49    pub embeddings: bool,
50    /// Text/vector indexes.
51    #[serde(default)]
52    pub indexes: bool,
53    /// Workflow definitions.
54    #[serde(default)]
55    pub workflows: bool,
56    /// Stored artifacts.
57    #[serde(default)]
58    pub artifacts: bool,
59    /// Cross-project sync.
60    #[serde(default)]
61    pub sync: bool,
62}
63
64impl Default for Capabilities {
65    fn default() -> Self {
66        // topology + properties are the baseline every project has.
67        Self {
68            topology: true,
69            properties: true,
70            documents: false,
71            provenance: false,
72            embeddings: false,
73            indexes: false,
74            workflows: false,
75            artifacts: false,
76            sync: false,
77        }
78    }
79}
80
81/// Deserialised `graphforge.yaml`.
82///
83/// Constructed via [`load`](Self::load) (existing project) or
84/// [`create_default`](Self::create_default) (new project).
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ProjectManifest {
87    /// Stable project identity (UUIDv7, generated at creation).
88    pub project_uuid: Uuid,
89    /// Human-readable project name.
90    pub name: String,
91    /// Manifest schema version.
92    pub version: String,
93    /// RFC 3339 creation timestamp.
94    pub created_at: String,
95    /// Relative path to the ontology file, or `None` if the project has none.
96    #[serde(default)]
97    pub ontology: Option<String>,
98    /// Explicit ontology mode override, or `None` to apply default-mode logic.
99    #[serde(default)]
100    pub ontology_mode: Option<OntologyMode>,
101    /// IR schema version the project was created with.
102    pub ir_version: String,
103    /// GraphForge version the project was created with.
104    pub graphforge_version: String,
105    /// Capability flags; absent block defaults to the baseline.
106    #[serde(default)]
107    pub capabilities: Option<Capabilities>,
108}
109
110impl ProjectManifest {
111    /// Read and parse `graphforge.yaml` from `dir`.
112    ///
113    /// # Errors
114    /// Returns [`GfError::Storage`] if the file cannot be read and
115    /// [`GfError::Validation`] if its contents are not valid manifest YAML.
116    pub fn load(dir: &Path) -> Result<Self, GfError> {
117        let path = dir.join(MANIFEST_FILE);
118        let text = std::fs::read_to_string(&path)
119            .map_err(|e| GfError::Storage(format!("failed to read {}: {e}", path.display())))?;
120        serde_yaml::from_str(&text)
121            .map_err(|e| GfError::Validation(format!("invalid {MANIFEST_FILE}: {e}")))
122    }
123
124    /// Create a new project: generate a UUIDv7 identity and write
125    /// `graphforge.yaml` into `dir`.
126    ///
127    /// `created_at` is supplied by the caller (RFC 3339) so this stays free of
128    /// ambient clock access; pass `None` to omit a timestamp.
129    ///
130    /// # Errors
131    /// Returns [`GfError::Storage`] if the manifest cannot be written, or
132    /// [`GfError::Validation`] if it cannot be serialised.
133    pub fn create_default(
134        dir: &Path,
135        name: &str,
136        created_at: impl Into<String>,
137    ) -> Result<Self, GfError> {
138        let manifest = Self {
139            project_uuid: crate::uuid::new_v7(),
140            name: name.to_owned(),
141            version: "1".to_owned(),
142            created_at: created_at.into(),
143            ontology: None,
144            ontology_mode: None,
145            ir_version: "0.1.0".to_owned(),
146            graphforge_version: env!("CARGO_PKG_VERSION").to_owned(),
147            capabilities: Some(Capabilities::default()),
148        };
149
150        let yaml = serde_yaml::to_string(&manifest).map_err(|e| {
151            GfError::Validation(format!("failed to serialise {MANIFEST_FILE}: {e}"))
152        })?;
153        let path = dir.join(MANIFEST_FILE);
154        std::fs::write(&path, yaml)
155            .map_err(|e| GfError::Storage(format!("failed to write {}: {e}", path.display())))?;
156
157        Ok(manifest)
158    }
159
160    /// Determine the effective [`OntologyMode`], applying default-mode logic.
161    ///
162    /// | Condition | Mode |
163    /// |---|---|
164    /// | `ontology_mode` set in manifest | that value |
165    /// | unset, configured `ontology` path exists in `dir` | [`Advisory`](OntologyMode::Advisory) |
166    /// | unset, no configured path, `ontology.yaml` present in `dir` | [`Advisory`](OntologyMode::Advisory) |
167    /// | unset, no ontology file present | [`Exploratory`](OntologyMode::Exploratory) |
168    ///
169    /// The configured `ontology` path is tested for existence (resolved
170    /// relative to `dir`) rather than merely being present in the manifest —
171    /// a manifest pointing at a missing file falls back to `Exploratory`.
172    #[must_use]
173    pub fn effective_ontology_mode(&self, dir: &Path) -> OntologyMode {
174        if let Some(mode) = self.ontology_mode {
175            return mode;
176        }
177        let has_ontology = self.ontology.as_deref().map_or_else(
178            || dir.join(ONTOLOGY_FILE).exists(),
179            |path| dir.join(path).exists(),
180        );
181        if has_ontology {
182            OntologyMode::Advisory
183        } else {
184            OntologyMode::Exploratory
185        }
186    }
187
188    /// Return the project's capabilities with baseline defaults applied.
189    ///
190    /// `topology` and `properties` are always enabled regardless of what the
191    /// manifest declares.
192    #[must_use]
193    pub fn enabled_capabilities(&self) -> Capabilities {
194        let mut caps = self.capabilities.clone().unwrap_or_default();
195        caps.topology = true;
196        caps.properties = true;
197        caps
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn temp_dir() -> std::path::PathBuf {
206        // A unique scratch dir keyed by a fresh UUID (no clock/rng helpers).
207        let dir = std::env::temp_dir().join(format!("gf-manifest-{}", crate::uuid::new_v7()));
208        std::fs::create_dir_all(&dir).unwrap();
209        dir
210    }
211
212    #[test]
213    fn create_default_round_trips() {
214        let dir = temp_dir();
215        let created =
216            ProjectManifest::create_default(&dir, "Investigation Alpha", "2026-06-02T00:00:00Z")
217                .unwrap();
218        let loaded = ProjectManifest::load(&dir).unwrap();
219
220        assert_eq!(loaded.project_uuid, created.project_uuid);
221        assert_eq!(loaded.name, "Investigation Alpha");
222        assert_eq!(loaded.version, "1");
223        assert_eq!(loaded.created_at, "2026-06-02T00:00:00Z");
224        assert_eq!(loaded.ontology, None);
225        assert_eq!(loaded.ontology_mode, None);
226        assert_eq!(loaded.ir_version, "0.1.0");
227
228        std::fs::remove_dir_all(&dir).ok();
229    }
230
231    #[test]
232    fn effective_mode_is_exploratory_without_ontology() {
233        let dir = temp_dir();
234        let m = ProjectManifest::create_default(&dir, "p", "2026-06-02T00:00:00Z").unwrap();
235        assert_eq!(m.effective_ontology_mode(&dir), OntologyMode::Exploratory);
236        std::fs::remove_dir_all(&dir).ok();
237    }
238
239    #[test]
240    fn effective_mode_is_advisory_when_ontology_file_present() {
241        let dir = temp_dir();
242        let m = ProjectManifest::create_default(&dir, "p", "2026-06-02T00:00:00Z").unwrap();
243        std::fs::write(dir.join(ONTOLOGY_FILE), "ontology_id: x\nversion: v1\n").unwrap();
244        assert_eq!(m.effective_ontology_mode(&dir), OntologyMode::Advisory);
245        std::fs::remove_dir_all(&dir).ok();
246    }
247
248    #[test]
249    fn configured_ontology_path_must_exist_for_advisory() {
250        let dir = temp_dir();
251        let mut m = ProjectManifest::create_default(&dir, "p", "2026-06-02T00:00:00Z").unwrap();
252        // Manifest points at a custom ontology path that does NOT exist on disk.
253        m.ontology = Some("custom/missing.yaml".to_owned());
254        assert_eq!(m.effective_ontology_mode(&dir), OntologyMode::Exploratory);
255
256        // Once that file is created, the mode flips to Advisory.
257        std::fs::create_dir_all(dir.join("custom")).unwrap();
258        std::fs::write(dir.join("custom/missing.yaml"), "ontology_id: x\n").unwrap();
259        assert_eq!(m.effective_ontology_mode(&dir), OntologyMode::Advisory);
260        std::fs::remove_dir_all(&dir).ok();
261    }
262
263    #[test]
264    fn explicit_mode_overrides_default_logic() {
265        let dir = temp_dir();
266        let mut m = ProjectManifest::create_default(&dir, "p", "2026-06-02T00:00:00Z").unwrap();
267        m.ontology_mode = Some(OntologyMode::Strict);
268        // Even with an ontology file present, the explicit mode wins.
269        std::fs::write(dir.join(ONTOLOGY_FILE), "ontology_id: x\n").unwrap();
270        assert_eq!(m.effective_ontology_mode(&dir), OntologyMode::Strict);
271        std::fs::remove_dir_all(&dir).ok();
272    }
273
274    #[test]
275    fn missing_capabilities_block_yields_baseline() {
276        // A manifest YAML with no `capabilities:` field at all.
277        let yaml = "\
278project_uuid: 0192f3a2-0000-7000-8000-000000000000
279name: minimal
280version: \"1\"
281created_at: \"2026-06-02T00:00:00Z\"
282ir_version: 0.1.0
283graphforge_version: 0.5.0
284";
285        let m: ProjectManifest = serde_yaml::from_str(yaml).unwrap();
286        assert!(m.capabilities.is_none());
287        let caps = m.enabled_capabilities();
288        assert!(caps.topology);
289        assert!(caps.properties);
290        assert!(!caps.documents);
291        assert!(!caps.embeddings);
292        assert!(!caps.sync);
293    }
294
295    #[test]
296    fn unknown_fields_are_ignored_for_forward_compat() {
297        let yaml = "\
298project_uuid: 0192f3a2-0000-7000-8000-000000000000
299name: future
300version: \"1\"
301created_at: \"2026-06-02T00:00:00Z\"
302ir_version: 0.1.0
303graphforge_version: 0.5.0
304some_future_field: 42
305nested_future:
306  a: 1
307";
308        let m: ProjectManifest = serde_yaml::from_str(yaml).unwrap();
309        assert_eq!(m.name, "future");
310    }
311
312    #[test]
313    fn partial_capabilities_default_missing_to_false() {
314        let yaml = "\
315project_uuid: 0192f3a2-0000-7000-8000-000000000000
316name: partial
317version: \"1\"
318created_at: \"2026-06-02T00:00:00Z\"
319ir_version: 0.1.0
320graphforge_version: 0.5.0
321capabilities:
322  embeddings: true
323";
324        let m: ProjectManifest = serde_yaml::from_str(yaml).unwrap();
325        let caps = m.capabilities.unwrap();
326        assert!(caps.embeddings);
327        // Unlisted flags fall back to false via #[serde(default)].
328        assert!(!caps.documents);
329        assert!(!caps.topology);
330    }
331}