Skip to main content

gobby_code/commands/codewiki/
system_model.rs

1//! Deterministic, no-LLM system model of the Cargo workspace.
2//!
3//! Leaf A of epic #886 (#887). This module reads facts straight off disk —
4//! the workspace root `Cargo.toml`, each member crate's `Cargo.toml`, and the
5//! `gobby-core` feature table — and turns them into a serializable
6//! [`SystemModel`]: the member crates, their workspace-internal dependency
7//! edges, the service boundaries the feature gates pull in, the runtime modes
8//! the binaries can operate in, and the per-crate enabled `gobby-core`
9//! features.
10//!
11//! Nothing here calls an LLM, touches the network, or hits a datastore. The
12//! later diagram/infra leaves (B/D) consume this model; this leaf only lands
13//! the model plus its extraction and tests.
14//!
15//! Robustness: a missing or malformed `Cargo.toml` degrades to a *partial*
16//! model — the offending crate is skipped and a note is recorded — rather than
17//! panicking or erroring out the whole build. No I/O or parse path unwraps.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::Path;
21
22use crate::index::hasher;
23
24/// Package name of the shared foundation crate whose `[features]` table
25/// defines the adapter feature gates the rest of the workspace opts into.
26const CORE_PACKAGE: &str = "gobby-core";
27
28/// A workspace member crate.
29#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
30pub struct Crate {
31    /// Cargo package name (e.g. `gobby-code`), not the directory name.
32    pub name: String,
33    /// Workspace-relative path to the crate directory (e.g. `crates/gcode`).
34    pub path: String,
35    /// Whether the crate ships a binary target (`[[bin]]` or `src/main.rs`).
36    pub is_binary: bool,
37    /// Whether the crate ships a library target (`[lib]` or `src/lib.rs`).
38    pub is_lib: bool,
39}
40
41/// A workspace-internal dependency edge: `from` depends on `to`, where both
42/// endpoints are member package names. Edges to external crates.io
43/// dependencies are never recorded.
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
45pub struct Edge {
46    /// Package name of the depending crate.
47    pub from: String,
48    /// Package name of the depended-upon workspace member.
49    pub to: String,
50}
51
52/// A runtime service / external boundary the workspace can talk to.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
54pub enum ServiceKind {
55    /// PostgreSQL hub (the `postgres` adapter feature).
56    Postgres,
57    /// FalkorDB code/relationship graph (the `falkor` adapter feature).
58    Falkor,
59    /// Qdrant vector store (the `qdrant` adapter feature).
60    Qdrant,
61    /// OpenAI-compatible embedding / completion API (the `ai` adapter feature,
62    /// direct transport).
63    EmbeddingApi,
64    /// Gobby daemon (the `ai` adapter routes through it; daemon URL resolution
65    /// lives in `gobby_core::daemon_url`).
66    Daemon,
67    /// `~/.gobby/hooks/inbox` enqueue path that `ghook` always writes to.
68    GhookInbox,
69    /// tree-sitter grammars: the AST parsing toolchain pulled in by a member
70    /// that depends on `tree-sitter` plus its `tree-sitter-*` grammar crates.
71    /// Detected from Cargo dependency names, not a `gobby-core` feature gate.
72    TreeSitter,
73    /// Document/Office toolchain (PDF + spreadsheet extraction) pulled in by a
74    /// member exposing a `documents` feature or a `pdf-extract`/`pdfium-*`
75    /// dependency. A Cargo-visible toolchain, not a `gobby-core` feature gate.
76    DocumentToolchain,
77    /// Media toolchain (ffmpeg, a system binary reached via `PATH`). ffmpeg
78    /// leaves no Cargo signal, so this boundary is detected by probing for the
79    /// media-ingest source file (`crates/gwiki/src/media.rs`).
80    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/// One service boundary the workspace reaches, plus what pulls it in.
100#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
101pub struct ServiceBoundary {
102    /// Stable, human-readable identifier for the boundary.
103    pub name: String,
104    /// Which kind of service this is.
105    pub kind: ServiceKind,
106    /// Crate+feature pairs responsible for pulling the boundary in, e.g.
107    /// `gobby-code (feature: postgres)` or `gobby-hooks (always)`. Sorted and
108    /// de-duplicated for determinism.
109    pub pulled_in_by: Vec<String>,
110}
111
112/// How a binary can run with respect to the AI routing decision.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
114pub enum RuntimeMode {
115    /// Direct/off routing: the CLI talks to datastores and the embedding API
116    /// itself, with no daemon in the loop.
117    Standalone,
118    /// Daemon routing: the CLI delegates AI (and scheduling) to the Gobby
119    /// daemon.
120    DaemonAttached,
121}
122
123/// Deterministic, serializable model of the workspace. Built with no LLM
124/// calls from facts on disk.
125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
126pub struct SystemModel {
127    /// Member crates, sorted by package name.
128    pub crates: Vec<Crate>,
129    /// Workspace-internal dependency edges, sorted by `(from, to)`.
130    pub edges: Vec<Edge>,
131    /// Service boundaries the workspace reaches, sorted by `(kind, name)`.
132    pub services: Vec<ServiceBoundary>,
133    /// Runtime modes the workspace can operate in (always both).
134    pub runtime_modes: Vec<RuntimeMode>,
135    /// Enabled `gobby-core` features per crate (package name → sorted feature
136    /// list). Only members with a `gobby-core` dependency appear.
137    pub features_by_crate: BTreeMap<String, Vec<String>>,
138    /// Non-fatal notes recorded while extracting a partial model (e.g. a
139    /// member `Cargo.toml` that could not be read or parsed). Empty for a
140    /// fully-resolved workspace.
141    pub notes: Vec<String>,
142}
143
144impl SystemModel {
145    /// Stable content digest of the whole model, used as the per-page-type
146    /// invalidation key for the architecture and infrastructure pages (Leaf H,
147    /// #893). All fields are deterministically ordered (sorted vecs, a
148    /// `BTreeMap`), so the canonical JSON encoding is reproducible and the
149    /// digest changes only on a structural workspace change — a crate, edge,
150    /// service boundary, runtime mode, or feature shift — not a function-body
151    /// edit. Serialization cannot realistically fail for this owned, plain-data
152    /// model; an empty encoding (and thus a stable digest) is the safe fallback.
153    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
159/// Build a [`SystemModel`] from the workspace rooted at `repo_root`.
160///
161/// Reads `repo_root/Cargo.toml` for `[workspace].members`, then each member's
162/// `Cargo.toml`. Any unreadable/unparseable manifest is skipped with a note;
163/// the function always returns a model and never panics.
164pub 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    // First pass: resolve every member's package name and target shape so we
170    // can recognise workspace-internal dependency edges by package name.
171    let mut crates: Vec<Crate> = Vec::new();
172    // (package name, parsed manifest), kept for the dependency pass.
173    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    // Second pass: dependency edges (workspace-internal only), per-crate
222    // gobby-core feature sets, and the raw dependency-name / feature-key sets
223    // the toolchain boundaries (tree-sitter / document / media) are detected
224    // from.
225    let mut edges: Vec<Edge> = Vec::new();
226    let mut features_by_crate: BTreeMap<String, Vec<String>> = BTreeMap::new();
227    // package name -> features enabled on its gobby-core dependency.
228    let mut core_features: BTreeMap<String, Vec<String>> = BTreeMap::new();
229    // package name -> every dependency name it declares (any dependency table).
230    let mut dep_names_by_crate: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
231    // package name -> the keys of its own `[features]` table.
232    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
280/// Read `[workspace].members` from the root manifest. On any failure, records a
281/// note and returns an empty member list (a fully-partial model).
282fn 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
318/// Extract `[package].name` from a parsed manifest.
319fn 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
327/// Whether the manifest declares a non-empty `[[<key>]]` table-array (e.g.
328/// `[[bin]]`).
329fn 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
336/// All dependency entries across `[dependencies]`, `[dev-dependencies]`, and
337/// `[build-dependencies]`. Returns `(dep_name, dep_value)` pairs. Target-scoped
338/// dependency tables are ignored — workspace-internal edges live in the plain
339/// tables for this workspace.
340fn 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
353/// Features enabled on a dependency entry. A bare `dep = "1"` string has no
354/// features; a `dep = { features = [...] }` table contributes its `features`
355/// array. Returns a sorted, de-duplicated list.
356fn 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
372/// Keys of a manifest's own `[features]` table (e.g. `documents`, `default`).
373/// Returns a sorted, de-duplicated set; an absent table yields an empty set.
374fn 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
382/// Map a `gobby-core` adapter feature to the service boundaries it pulls in.
383/// Mirrors `crates/gcore/Cargo.toml` `[features]`: `ai` enables the AI
384/// transport, which can reach both the embedding API directly and the daemon.
385fn 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
395/// Stable display name for a service kind.
396fn 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
410/// Derive the service boundaries from the per-crate `gobby-core` feature sets,
411/// plus the two always-present boundaries: `ghook` always enqueues to its
412/// inbox, and the daemon URL resolver lives in always-compiled
413/// `gobby_core::daemon_url`.
414fn 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    // kind -> set of "crate (feature: x)" provenance strings.
422    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    // ghook enqueues to ~/.gobby/hooks/inbox regardless of features, but only
436    // assert that boundary when the hook-dispatcher member was parsed.
437    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    // The daemon URL resolver is always-compiled in gobby_core::daemon_url, so
448    // the daemon boundary exists for the workspace even absent the `ai`
449    // feature. Confirm gcore is present before asserting this.
450    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    // Toolchain boundaries (tree-sitter / document / media) detected from real
458    // on-disk facts rather than gobby-core feature gates. Each contributes a
459    // provenance string keyed by kind, merged into the same map so the final
460    // sort/dedup keeps everything deterministic.
461    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
479/// Detect the toolchain service boundaries the workspace reaches from real
480/// on-disk Cargo facts plus a file probe (ffmpeg leaves no Cargo signal):
481///
482/// - **tree-sitter**: any member that depends on a crate named exactly
483///   `tree-sitter`; provenance counts that crate's `tree-sitter-*` grammar
484///   dependencies.
485/// - **document toolchain**: any member exposing a `documents` feature or a
486///   `pdf-extract` / `pdfium-render` / `pdfium-auto` dependency.
487/// - **media toolchain**: the `crates/gwiki` member when its `src/media.rs`
488///   exists on disk (the same kind of probe the always-on daemon boundary
489///   uses for `crates/gcore/Cargo.toml`).
490///
491/// Returns `(kind, provenance)` pairs; an absent toolchain yields no pair, so
492/// the boundary is simply omitted from the model.
493fn 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        // tree-sitter: a `tree-sitter` dependency plus N `tree-sitter-*`
504        // grammar crates.
505        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        // Document toolchain: a `documents` feature key OR a PDF/Office dep.
517        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    // Media toolchain: ffmpeg via PATH, detected by probing the gwiki crate's
539    // media-ingest source file. Derive the gwiki package name from the model
540    // by its workspace-relative crate path.
541    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    /// Write a minimal fixture workspace under a fresh temp dir and return its
560    /// root path (kept alive by the returned `TempDir`).
561    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        // A lib-only foundation crate and a bin crate that depends on it. The
593        // dir name (`app`) differs from the package name (`my-app`) to exercise
594        // package-name resolution for edges.
595        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        // crates are sorted by package name: my-app, my-core.
610        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        // Only the workspace-internal edge is recorded; serde (crates.io) is
620        // not an edge.
621        assert_eq!(
622            model.edges,
623            vec![Edge {
624                from: "my-app".to_string(),
625                to: "my-core".to_string(),
626            }]
627        );
628
629        // Both runtime modes are always present.
630        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        // The foundation crate is named gobby-core so feature resolution
637        // matches the real adapter naming; a member enables postgres+qdrant.
638        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        // features_by_crate records the enabled gobby-core features, sorted.
653        assert_eq!(
654            model.features_by_crate.get("gobby-code"),
655            Some(&vec!["postgres".to_string(), "qdrant".to_string()])
656        );
657
658        // Postgres boundary, pulled in by gobby-code (feature: postgres).
659        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        // Qdrant boundary, pulled in by gobby-code (feature: qdrant).
670        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        // No `ai` feature was enabled, so there is no EmbeddingApi boundary.
681        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        // The daemon boundary is always present (gobby_core::daemon_url), but
690        // ghook inbox needs the crates/ghook binary member.
691        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        // Daemon is pulled in both by the ai feature AND the always-on
741        // daemon_url resolver; both provenance strings appear (sorted).
742        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        // Rewrite the root manifest to list two extra members that do not have
761        // valid manifests on disk: one missing entirely, one malformed.
762        fs::write(
763            root.join("Cargo.toml"),
764            "[workspace]\nmembers = [\"crates/good\", \"crates/missing\", \"crates/broken\"]\n",
765        )
766        .expect("rewrite root manifest");
767        // Malformed member manifest (invalid TOML).
768        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        // crates/missing has no Cargo.toml at all.
775
776        let model = build_system_model(&root);
777
778        // Only the good crate survives; no panic occurred.
779        assert_eq!(model.crates.len(), 1);
780        assert_eq!(model.crates[0].name, "good-crate");
781
782        // Two notes: the missing read and the malformed parse.
783        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        // Runtime modes still both present even in a partial model.
788        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        // Runtime modes are unconditional.
803        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        // A member that depends on `tree-sitter` plus two grammar crates pulls
812        // in the TreeSitter boundary; the provenance counts the grammars.
813        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        // A member exposing a `documents` feature pulls in the DocumentToolchain
838        // boundary even without any PDF dependency named explicitly.
839        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        // A plain lib crate with no tree-sitter dep, no documents feature, and
882        // no crates/gwiki member yields none of the toolchain boundaries.
883        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}