1use std::collections::{BTreeMap, BTreeSet};
20use std::path::Path;
21
22use crate::index::hasher;
23
24const CORE_PACKAGE: &str = "gobby-core";
27
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
30pub struct Crate {
31 pub name: String,
33 pub path: String,
35 pub is_binary: bool,
37 pub is_lib: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
45pub struct Edge {
46 pub from: String,
48 pub to: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
54pub enum ServiceKind {
55 Postgres,
57 Falkor,
59 Qdrant,
61 EmbeddingApi,
64 Daemon,
67 GhookInbox,
69 TreeSitter,
73 DocumentToolchain,
77 MediaToolchain,
81}
82
83impl ServiceKind {
84 pub(crate) fn kind_slug(self) -> &'static str {
85 match self {
86 Self::Postgres => "postgres",
87 Self::Falkor => "falkor",
88 Self::Qdrant => "qdrant",
89 Self::EmbeddingApi => "embedding_api",
90 Self::Daemon => "daemon",
91 Self::GhookInbox => "ghook_inbox",
92 Self::TreeSitter => "tree_sitter",
93 Self::DocumentToolchain => "document_toolchain",
94 Self::MediaToolchain => "media_toolchain",
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
101pub struct ServiceBoundary {
102 pub name: String,
104 pub kind: ServiceKind,
106 pub pulled_in_by: Vec<String>,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
114pub enum RuntimeMode {
115 Standalone,
118 DaemonAttached,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
126pub struct SystemModel {
127 pub crates: Vec<Crate>,
129 pub edges: Vec<Edge>,
131 pub services: Vec<ServiceBoundary>,
133 pub runtime_modes: Vec<RuntimeMode>,
135 pub features_by_crate: BTreeMap<String, Vec<String>>,
138 pub notes: Vec<String>,
142}
143
144impl SystemModel {
145 pub(crate) fn digest(&self) -> String {
154 let encoded = serde_json::to_vec(self).unwrap_or_default();
155 hasher::content_hash(&encoded)
156 }
157}
158
159pub fn build_system_model(repo_root: &Path) -> SystemModel {
165 let mut notes = Vec::new();
166
167 let members = workspace_members(repo_root, &mut notes);
168
169 let mut crates: Vec<Crate> = Vec::new();
172 let mut manifests: Vec<(String, toml::Value)> = Vec::new();
174
175 for member in &members {
176 let manifest_path = repo_root.join(member).join("Cargo.toml");
177 let raw = match std::fs::read_to_string(&manifest_path) {
178 Ok(raw) => raw,
179 Err(err) => {
180 notes.push(format!(
181 "skipped member `{member}`: cannot read {}: {err}",
182 manifest_path.display()
183 ));
184 continue;
185 }
186 };
187 let manifest: toml::Value = match toml::from_str::<toml::Value>(&raw) {
188 Ok(value) => value,
189 Err(err) => {
190 notes.push(format!(
191 "skipped member `{member}`: malformed {}: {err}",
192 manifest_path.display()
193 ));
194 continue;
195 }
196 };
197
198 let Some(name) = package_name(&manifest) else {
199 notes.push(format!(
200 "skipped member `{member}`: manifest has no [package].name"
201 ));
202 continue;
203 };
204
205 let crate_dir = repo_root.join(member);
206 let is_binary =
207 has_table_array(&manifest, "bin") || crate_dir.join("src/main.rs").is_file();
208 let is_lib = manifest.get("lib").is_some() || crate_dir.join("src/lib.rs").is_file();
209
210 crates.push(Crate {
211 name: name.clone(),
212 path: member.clone(),
213 is_binary,
214 is_lib,
215 });
216 manifests.push((name, manifest));
217 }
218
219 let member_names: BTreeSet<String> = crates.iter().map(|c| c.name.clone()).collect();
220
221 let mut edges: Vec<Edge> = Vec::new();
226 let mut features_by_crate: BTreeMap<String, Vec<String>> = BTreeMap::new();
227 let mut core_features: BTreeMap<String, Vec<String>> = BTreeMap::new();
229 let mut dep_names_by_crate: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
231 let mut feature_keys_by_crate: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
233
234 for (name, manifest) in &manifests {
235 for (dep_name, dep_value) in dependency_entries(manifest) {
236 if member_names.contains(&dep_name) && dep_name != *name {
237 edges.push(Edge {
238 from: name.clone(),
239 to: dep_name.clone(),
240 });
241 }
242 if dep_name == CORE_PACKAGE {
243 let feats = dependency_features(&dep_value);
244 features_by_crate.insert(name.clone(), feats.clone());
245 core_features.insert(name.clone(), feats);
246 }
247 dep_names_by_crate
248 .entry(name.clone())
249 .or_default()
250 .insert(dep_name);
251 }
252 let feature_keys = feature_table_keys(manifest);
253 if !feature_keys.is_empty() {
254 feature_keys_by_crate.insert(name.clone(), feature_keys);
255 }
256 }
257
258 edges.sort();
259 edges.dedup();
260 crates.sort_by(|a, b| a.name.cmp(&b.name));
261
262 let services = service_boundaries(
263 &core_features,
264 &dep_names_by_crate,
265 &feature_keys_by_crate,
266 &crates,
267 repo_root,
268 );
269
270 SystemModel {
271 crates,
272 edges,
273 services,
274 runtime_modes: vec![RuntimeMode::Standalone, RuntimeMode::DaemonAttached],
275 features_by_crate,
276 notes,
277 }
278}
279
280fn workspace_members(repo_root: &Path, notes: &mut Vec<String>) -> Vec<String> {
283 let root_manifest = repo_root.join("Cargo.toml");
284 let raw = match std::fs::read_to_string(&root_manifest) {
285 Ok(raw) => raw,
286 Err(err) => {
287 notes.push(format!(
288 "cannot read workspace manifest {}: {err}",
289 root_manifest.display()
290 ));
291 return Vec::new();
292 }
293 };
294 let value: toml::Value = match toml::from_str::<toml::Value>(&raw) {
295 Ok(value) => value,
296 Err(err) => {
297 notes.push(format!(
298 "malformed workspace manifest {}: {err}",
299 root_manifest.display()
300 ));
301 return Vec::new();
302 }
303 };
304 let members = value
305 .get("workspace")
306 .and_then(|w| w.get("members"))
307 .and_then(|m| m.as_array());
308 let Some(members) = members else {
309 notes.push("workspace manifest has no [workspace].members array".to_string());
310 return Vec::new();
311 };
312 members
313 .iter()
314 .filter_map(|m| m.as_str().map(str::to_string))
315 .collect()
316}
317
318fn package_name(manifest: &toml::Value) -> Option<String> {
320 manifest
321 .get("package")
322 .and_then(|p| p.get("name"))
323 .and_then(|n| n.as_str())
324 .map(str::to_string)
325}
326
327fn has_table_array(manifest: &toml::Value, key: &str) -> bool {
330 manifest
331 .get(key)
332 .and_then(|v| v.as_array())
333 .is_some_and(|arr| !arr.is_empty())
334}
335
336fn dependency_entries(manifest: &toml::Value) -> Vec<(String, toml::Value)> {
341 const TABLES: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
342 let mut out = Vec::new();
343 for table in TABLES {
344 if let Some(deps) = manifest.get(table).and_then(|t| t.as_table()) {
345 for (name, value) in deps {
346 out.push((name.clone(), value.clone()));
347 }
348 }
349 }
350 out
351}
352
353fn dependency_features(dep_value: &toml::Value) -> Vec<String> {
357 let mut feats: Vec<String> = dep_value
358 .as_table()
359 .and_then(|t| t.get("features"))
360 .and_then(|f| f.as_array())
361 .map(|arr| {
362 arr.iter()
363 .filter_map(|v| v.as_str().map(str::to_string))
364 .collect()
365 })
366 .unwrap_or_default();
367 feats.sort();
368 feats.dedup();
369 feats
370}
371
372fn feature_table_keys(manifest: &toml::Value) -> BTreeSet<String> {
375 manifest
376 .get("features")
377 .and_then(|f| f.as_table())
378 .map(|table| table.keys().cloned().collect())
379 .unwrap_or_default()
380}
381
382fn feature_services(feature: &str) -> &'static [ServiceKind] {
386 match feature {
387 "postgres" => &[ServiceKind::Postgres],
388 "falkor" => &[ServiceKind::Falkor],
389 "qdrant" => &[ServiceKind::Qdrant],
390 "ai" => &[ServiceKind::EmbeddingApi, ServiceKind::Daemon],
391 _ => &[],
392 }
393}
394
395fn service_name(kind: ServiceKind) -> &'static str {
397 match kind {
398 ServiceKind::Postgres => "PostgreSQL hub",
399 ServiceKind::Falkor => "FalkorDB graph",
400 ServiceKind::Qdrant => "Qdrant vectors",
401 ServiceKind::EmbeddingApi => "Embedding API",
402 ServiceKind::Daemon => "Gobby daemon",
403 ServiceKind::GhookInbox => "ghook inbox",
404 ServiceKind::TreeSitter => "tree-sitter grammars",
405 ServiceKind::DocumentToolchain => "Document toolchain (PDF/Office)",
406 ServiceKind::MediaToolchain => "Media toolchain (ffmpeg)",
407 }
408}
409
410fn service_boundaries(
415 core_features: &BTreeMap<String, Vec<String>>,
416 dep_names_by_crate: &BTreeMap<String, BTreeSet<String>>,
417 feature_keys_by_crate: &BTreeMap<String, BTreeSet<String>>,
418 crates: &[Crate],
419 repo_root: &Path,
420) -> Vec<ServiceBoundary> {
421 let mut by_kind: BTreeMap<ServiceKind, BTreeSet<String>> = BTreeMap::new();
423
424 for (crate_name, feats) in core_features {
425 for feat in feats {
426 for kind in feature_services(feat) {
427 by_kind
428 .entry(*kind)
429 .or_default()
430 .insert(format!("{crate_name} (feature: {feat})"));
431 }
432 }
433 }
434
435 if let Some(ghook_owner) = crates
438 .iter()
439 .find(|c| c.is_binary && !c.is_lib && c.path == "crates/ghook")
440 {
441 by_kind
442 .entry(ServiceKind::GhookInbox)
443 .or_default()
444 .insert(format!("{} (always)", ghook_owner.name));
445 }
446
447 if repo_root.join("crates/gcore/Cargo.toml").is_file() {
451 by_kind
452 .entry(ServiceKind::Daemon)
453 .or_default()
454 .insert("workspace (gobby_core::daemon_url, always)".to_string());
455 }
456
457 for (kind, provenance) in
462 toolchain_boundaries(dep_names_by_crate, feature_keys_by_crate, crates, repo_root)
463 {
464 by_kind.entry(kind).or_default().insert(provenance);
465 }
466
467 let mut services: Vec<ServiceBoundary> = by_kind
468 .into_iter()
469 .map(|(kind, provenance)| ServiceBoundary {
470 name: service_name(kind).to_string(),
471 kind,
472 pulled_in_by: provenance.into_iter().collect(),
473 })
474 .collect();
475 services.sort_by(|a, b| (a.kind, &a.name).cmp(&(b.kind, &b.name)));
476 services
477}
478
479fn toolchain_boundaries(
494 dep_names_by_crate: &BTreeMap<String, BTreeSet<String>>,
495 feature_keys_by_crate: &BTreeMap<String, BTreeSet<String>>,
496 crates: &[Crate],
497 repo_root: &Path,
498) -> Vec<(ServiceKind, String)> {
499 const PDF_DEPS: [&str; 3] = ["pdf-extract", "pdfium-render", "pdfium-auto"];
500 let mut out: Vec<(ServiceKind, String)> = Vec::new();
501
502 for (crate_name, deps) in dep_names_by_crate {
503 if deps.contains("tree-sitter") {
506 let grammar_count = deps
507 .iter()
508 .filter(|dep| dep.starts_with("tree-sitter-"))
509 .count();
510 out.push((
511 ServiceKind::TreeSitter,
512 format!("{crate_name} (deps: tree-sitter + {grammar_count} grammars)"),
513 ));
514 }
515
516 let has_documents_feature = feature_keys_by_crate
518 .get(crate_name)
519 .is_some_and(|keys| keys.contains("documents"));
520 let pdf_deps = PDF_DEPS
521 .iter()
522 .filter(|dep| deps.contains(**dep))
523 .copied()
524 .collect::<Vec<_>>();
525 if has_documents_feature {
526 out.push((
527 ServiceKind::DocumentToolchain,
528 format!("{crate_name} (feature: documents)"),
529 ));
530 } else if !pdf_deps.is_empty() {
531 out.push((
532 ServiceKind::DocumentToolchain,
533 format!("{crate_name} (deps: {})", pdf_deps.join(", ")),
534 ));
535 }
536 }
537
538 if let Some(gwiki) = crates.iter().find(|c| c.path == "crates/gwiki")
542 && repo_root.join(&gwiki.path).join("src/media.rs").is_file()
543 {
544 out.push((
545 ServiceKind::MediaToolchain,
546 format!("{} (src/media.rs, ffmpeg via PATH)", gwiki.name),
547 ));
548 }
549
550 out
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use std::fs;
557 use std::path::PathBuf;
558
559 fn fixture_workspace(members: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
562 let dir = tempfile::tempdir().expect("create temp dir");
563 let root = dir.path().to_path_buf();
564 let member_list = members
565 .iter()
566 .map(|(path, _)| format!("\"{path}\""))
567 .collect::<Vec<_>>()
568 .join(", ");
569 fs::write(
570 root.join("Cargo.toml"),
571 format!("[workspace]\nmembers = [{member_list}]\nresolver = \"3\"\n"),
572 )
573 .expect("write root manifest");
574 for (path, manifest) in members {
575 let crate_dir = root.join(path);
576 fs::create_dir_all(crate_dir.join("src")).expect("create crate dir");
577 fs::write(crate_dir.join("Cargo.toml"), manifest).expect("write member manifest");
578 }
579 (dir, root)
580 }
581
582 fn crate_named<'a>(model: &'a SystemModel, name: &str) -> &'a Crate {
583 model
584 .crates
585 .iter()
586 .find(|c| c.name == name)
587 .unwrap_or_else(|| panic!("crate `{name}` missing from model"))
588 }
589
590 #[test]
591 fn extracts_crates_internal_edges_and_target_shape() {
592 let lib_manifest = "[package]\nname = \"my-core\"\nversion = \"0.1.0\"\n\n[lib]\nname = \"my_core\"\npath = \"src/lib.rs\"\n";
596 let bin_manifest = "[package]\nname = \"my-app\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"app\"\npath = \"src/main.rs\"\n\n[dependencies]\nmy-core = { path = \"../core\" }\nserde = \"1\"\n";
597 let (_dir, root) =
598 fixture_workspace(&[("crates/core", lib_manifest), ("crates/app", bin_manifest)]);
599
600 let model = build_system_model(&root);
601
602 assert!(
603 model.notes.is_empty(),
604 "unexpected notes: {:?}",
605 model.notes
606 );
607 assert_eq!(model.crates.len(), 2);
608
609 let app = crate_named(&model, "my-app");
611 assert!(app.is_binary);
612 assert!(!app.is_lib);
613 assert_eq!(app.path, "crates/app");
614
615 let core = crate_named(&model, "my-core");
616 assert!(!core.is_binary);
617 assert!(core.is_lib);
618
619 assert_eq!(
622 model.edges,
623 vec![Edge {
624 from: "my-app".to_string(),
625 to: "my-core".to_string(),
626 }]
627 );
628
629 assert!(model.runtime_modes.contains(&RuntimeMode::Standalone));
631 assert!(model.runtime_modes.contains(&RuntimeMode::DaemonAttached));
632 }
633
634 #[test]
635 fn maps_core_features_to_service_boundaries() {
636 let core_manifest = "[package]\nname = \"gobby-core\"\nversion = \"0.5.0\"\n\n[lib]\nname = \"gobby_core\"\npath = \"src/lib.rs\"\n\n[features]\npostgres = []\nqdrant = []\nfalkor = []\nai = []\n";
639 let consumer_manifest = "[package]\nname = \"gobby-code\"\nversion = \"1.0.0\"\n\n[[bin]]\nname = \"gcode\"\npath = \"src/main.rs\"\n\n[dependencies]\ngobby-core = { path = \"../gcore\", features = [\"postgres\", \"qdrant\"] }\n";
640 let (_dir, root) = fixture_workspace(&[
641 ("crates/gcore", core_manifest),
642 ("crates/gcode", consumer_manifest),
643 ]);
644
645 let model = build_system_model(&root);
646 assert!(
647 model.notes.is_empty(),
648 "unexpected notes: {:?}",
649 model.notes
650 );
651
652 assert_eq!(
654 model.features_by_crate.get("gobby-code"),
655 Some(&vec!["postgres".to_string(), "qdrant".to_string()])
656 );
657
658 let pg = model
660 .services
661 .iter()
662 .find(|s| s.kind == ServiceKind::Postgres)
663 .expect("Postgres boundary present");
664 assert_eq!(
665 pg.pulled_in_by,
666 vec!["gobby-code (feature: postgres)".to_string()]
667 );
668
669 let qd = model
671 .services
672 .iter()
673 .find(|s| s.kind == ServiceKind::Qdrant)
674 .expect("Qdrant boundary present");
675 assert_eq!(
676 qd.pulled_in_by,
677 vec!["gobby-code (feature: qdrant)".to_string()]
678 );
679
680 assert!(
682 !model
683 .services
684 .iter()
685 .any(|s| s.kind == ServiceKind::EmbeddingApi),
686 "EmbeddingApi must not appear without the ai feature"
687 );
688
689 assert!(model.services.iter().any(|s| s.kind == ServiceKind::Daemon));
692 assert!(
693 model
694 .services
695 .iter()
696 .all(|s| s.kind != ServiceKind::GhookInbox)
697 );
698 }
699
700 #[test]
701 fn ghook_binary_member_yields_inbox_boundary() {
702 let core_manifest = "[package]\nname = \"gobby-core\"\nversion = \"0.5.0\"\n\n[lib]\npath = \"src/lib.rs\"\n";
703 let ghook_manifest = "[package]\nname = \"gobby-hooks\"\nversion = \"0.5.0\"\n\n[[bin]]\nname = \"ghook\"\npath = \"src/main.rs\"\n";
704 let (_dir, root) = fixture_workspace(&[
705 ("crates/gcore", core_manifest),
706 ("crates/ghook", ghook_manifest),
707 ]);
708
709 let model = build_system_model(&root);
710 let inbox = model
711 .services
712 .iter()
713 .find(|service| service.kind == ServiceKind::GhookInbox)
714 .expect("ghook inbox boundary present");
715
716 assert_eq!(inbox.pulled_in_by, vec!["gobby-hooks (always)".to_string()]);
717 }
718
719 #[test]
720 fn ai_feature_pulls_in_embedding_api_and_daemon() {
721 let core_manifest = "[package]\nname = \"gobby-core\"\nversion = \"0.5.0\"\n\n[lib]\nname = \"gobby_core\"\npath = \"src/lib.rs\"\n\n[features]\nai = []\n";
722 let consumer_manifest = "[package]\nname = \"gobby-wiki\"\nversion = \"0.5.0\"\n\n[[bin]]\nname = \"gwiki\"\npath = \"src/main.rs\"\n\n[dependencies]\ngobby-core = { path = \"../gcore\", features = [\"ai\"] }\n";
723 let (_dir, root) = fixture_workspace(&[
724 ("crates/gcore", core_manifest),
725 ("crates/gwiki", consumer_manifest),
726 ]);
727
728 let model = build_system_model(&root);
729
730 let embed = model
731 .services
732 .iter()
733 .find(|s| s.kind == ServiceKind::EmbeddingApi)
734 .expect("EmbeddingApi boundary present");
735 assert_eq!(
736 embed.pulled_in_by,
737 vec!["gobby-wiki (feature: ai)".to_string()]
738 );
739
740 let daemon = model
743 .services
744 .iter()
745 .find(|s| s.kind == ServiceKind::Daemon)
746 .expect("Daemon boundary present");
747 assert!(
748 daemon
749 .pulled_in_by
750 .contains(&"gobby-wiki (feature: ai)".to_string())
751 );
752 assert!(daemon.pulled_in_by.iter().any(|p| p.contains("daemon_url")));
753 }
754
755 #[test]
756 fn degrades_to_partial_model_on_missing_and_malformed_manifests() {
757 let good_manifest = "[package]\nname = \"good-crate\"\nversion = \"0.1.0\"\n\n[lib]\npath = \"src/lib.rs\"\n";
758 let (_dir, root) = fixture_workspace(&[("crates/good", good_manifest)]);
759
760 fs::write(
763 root.join("Cargo.toml"),
764 "[workspace]\nmembers = [\"crates/good\", \"crates/missing\", \"crates/broken\"]\n",
765 )
766 .expect("rewrite root manifest");
767 fs::create_dir_all(root.join("crates/broken")).expect("create broken dir");
769 fs::write(
770 root.join("crates/broken/Cargo.toml"),
771 "this is not = valid toml [[[",
772 )
773 .expect("write broken manifest");
774 let model = build_system_model(&root);
777
778 assert_eq!(model.crates.len(), 1);
780 assert_eq!(model.crates[0].name, "good-crate");
781
782 assert_eq!(model.notes.len(), 2, "notes: {:?}", model.notes);
784 assert!(model.notes.iter().any(|n| n.contains("crates/missing")));
785 assert!(model.notes.iter().any(|n| n.contains("crates/broken")));
786
787 assert_eq!(
789 model.runtime_modes,
790 vec![RuntimeMode::Standalone, RuntimeMode::DaemonAttached]
791 );
792 }
793
794 #[test]
795 fn missing_workspace_manifest_yields_empty_partial_model() {
796 let dir = tempfile::tempdir().expect("temp dir");
797 let model = build_system_model(dir.path());
798 assert!(model.crates.is_empty());
799 assert!(model.edges.is_empty());
800 assert_eq!(model.notes.len(), 1);
801 assert!(model.notes[0].contains("cannot read workspace manifest"));
802 assert_eq!(
804 model.runtime_modes,
805 vec![RuntimeMode::Standalone, RuntimeMode::DaemonAttached]
806 );
807 }
808
809 #[test]
810 fn tree_sitter_dep_yields_tree_sitter_boundary_with_grammar_count() {
811 let manifest = "[package]\nname = \"parser-crate\"\nversion = \"0.1.0\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[dependencies]\ntree-sitter = \"0.25\"\ntree-sitter-rust = \"0.24\"\ntree-sitter-python = \"0.25\"\nserde = \"1\"\n";
814 let (_dir, root) = fixture_workspace(&[("crates/parser", manifest)]);
815
816 let model = build_system_model(&root);
817 assert!(
818 model.notes.is_empty(),
819 "unexpected notes: {:?}",
820 model.notes
821 );
822
823 let ts = model
824 .services
825 .iter()
826 .find(|s| s.kind == ServiceKind::TreeSitter)
827 .expect("TreeSitter boundary present");
828 assert_eq!(ts.name, "tree-sitter grammars");
829 assert_eq!(
830 ts.pulled_in_by,
831 vec!["parser-crate (deps: tree-sitter + 2 grammars)".to_string()]
832 );
833 }
834
835 #[test]
836 fn documents_feature_yields_document_toolchain_boundary() {
837 let manifest = "[package]\nname = \"vault-crate\"\nversion = \"0.1.0\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[features]\ndefault = [\"documents\"]\ndocuments = [\"dep:pdf-extract\"]\n\n[dependencies]\npdf-extract = { version = \"0.10\", optional = true }\n";
840 let (_dir, root) = fixture_workspace(&[("crates/vault", manifest)]);
841
842 let model = build_system_model(&root);
843 assert!(
844 model.notes.is_empty(),
845 "unexpected notes: {:?}",
846 model.notes
847 );
848
849 let docs = model
850 .services
851 .iter()
852 .find(|s| s.kind == ServiceKind::DocumentToolchain)
853 .expect("DocumentToolchain boundary present");
854 assert_eq!(docs.name, "Document toolchain (PDF/Office)");
855 assert_eq!(
856 docs.pulled_in_by,
857 vec!["vault-crate (feature: documents)".to_string()]
858 );
859 }
860
861 #[test]
862 fn pdf_dep_yields_dependency_based_document_toolchain_boundary() {
863 let manifest = "[package]\nname = \"pdf-crate\"\nversion = \"0.1.0\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[dependencies]\npdf-extract = \"0.10\"\n";
864 let (_dir, root) = fixture_workspace(&[("crates/pdf", manifest)]);
865
866 let model = build_system_model(&root);
867 let docs = model
868 .services
869 .iter()
870 .find(|s| s.kind == ServiceKind::DocumentToolchain)
871 .expect("DocumentToolchain boundary present");
872
873 assert_eq!(
874 docs.pulled_in_by,
875 vec!["pdf-crate (deps: pdf-extract)".to_string()]
876 );
877 }
878
879 #[test]
880 fn workspace_without_toolchains_omits_those_boundaries() {
881 let manifest = "[package]\nname = \"plain-crate\"\nversion = \"0.1.0\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[dependencies]\nserde = \"1\"\n";
884 let (_dir, root) = fixture_workspace(&[("crates/plain", manifest)]);
885
886 let model = build_system_model(&root);
887 assert!(
888 model.notes.is_empty(),
889 "unexpected notes: {:?}",
890 model.notes
891 );
892
893 assert!(
894 !model
895 .services
896 .iter()
897 .any(|s| s.kind == ServiceKind::TreeSitter),
898 "TreeSitter must be omitted when no tree-sitter dep exists"
899 );
900 assert!(
901 !model
902 .services
903 .iter()
904 .any(|s| s.kind == ServiceKind::DocumentToolchain),
905 "DocumentToolchain must be omitted with no documents feature / pdf dep"
906 );
907 assert!(
908 !model
909 .services
910 .iter()
911 .any(|s| s.kind == ServiceKind::MediaToolchain),
912 "MediaToolchain must be omitted without a crates/gwiki member"
913 );
914 }
915}