graphforge_core/
manifest.rs1use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::{GfError, OntologyMode};
16
17pub const MANIFEST_FILE: &str = "graphforge.yaml";
19pub const ONTOLOGY_FILE: &str = "ontology.yaml";
21
22#[allow(clippy::struct_excessive_bools)]
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Capabilities {
35 #[serde(default)]
37 pub topology: bool,
38 #[serde(default)]
40 pub properties: bool,
41 #[serde(default)]
43 pub documents: bool,
44 #[serde(default)]
46 pub provenance: bool,
47 #[serde(default)]
49 pub embeddings: bool,
50 #[serde(default)]
52 pub indexes: bool,
53 #[serde(default)]
55 pub workflows: bool,
56 #[serde(default)]
58 pub artifacts: bool,
59 #[serde(default)]
61 pub sync: bool,
62}
63
64impl Default for Capabilities {
65 fn default() -> Self {
66 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#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ProjectManifest {
87 pub project_uuid: Uuid,
89 pub name: String,
91 pub version: String,
93 pub created_at: String,
95 #[serde(default)]
97 pub ontology: Option<String>,
98 #[serde(default)]
100 pub ontology_mode: Option<OntologyMode>,
101 pub ir_version: String,
103 pub graphforge_version: String,
105 #[serde(default)]
107 pub capabilities: Option<Capabilities>,
108}
109
110impl ProjectManifest {
111 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 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 #[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 #[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 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 m.ontology = Some("custom/missing.yaml".to_owned());
254 assert_eq!(m.effective_ontology_mode(&dir), OntologyMode::Exploratory);
255
256 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 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 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 assert!(!caps.documents);
329 assert!(!caps.topology);
330 }
331}