Skip to main content

memstead_base/ingest/
resolve.rs

1//! Ingest runtime resolution — turn a stored [`Ingest`] config plus the
2//! rest of the four-primitive [`PipelineConfigs`] into a [`ResolvedIngest`]:
3//! the ingest joined to its projection, and the projection's source facets
4//! joined to the mediums they engage, in the shape the selection, backoff,
5//! change-detection, and brief-assembly stages all read.
6//!
7//! This is the engine-side port of the Claude-Code plugin's
8//! `workspace-loader.mjs` `assembleIngest` + `loadFourPrimitiveStore`
9//! resolution (the structural join). It is a pure transformation over
10//! already-loaded configs — no I/O, so it is unit-testable without a
11//! workspace. Resolving a source's *change-detection strategy* (which reads
12//! the medium's declared strategy and probes the filesystem for a git work
13//! tree) is a separate, filesystem-touching concern that lands with the
14//! slice drivers that consume it.
15//!
16//! Where the plugin tolerated dangling references (a projection naming a
17//! facet that does not exist resolved to an empty facet view), this port
18//! **errors** with a located [`ResolveError`] instead — a dangling
19//! reference is a config-integrity bug, and surfacing it beats carrying a
20//! half-resolved source into brief assembly. The observable ingest
21//! behaviour (which facets/mediums a well-formed config resolves to) is
22//! preserved.
23
24use std::path::{Path, PathBuf};
25
26use crate::binding::{BindingV1, BuildMode, ResolvedBinding, medium_capabilities};
27use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry};
28use crate::pipeline_store::{BindingConfigs, PipelineConfigs};
29
30/// A projection source resolved to what the run needs: a **primary** facet
31/// joined to its medium (the territory to read and write back), or a
32/// read-only **reference** mem supplying cross-mem context.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ResolvedSource {
35    /// A source facet joined to the medium it engages.
36    Primary(ResolvedPrimarySource),
37    /// A read-only reference mem (cross-mem context, never written).
38    Reference {
39        /// The reference mem's id.
40        mem: String,
41    },
42}
43
44/// A primary source: a facet's selection over a medium, plus the medium's
45/// type and pointer (what kind of territory it is and where it lives).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResolvedPrimarySource {
48    /// The facet's name (as referenced by the projection).
49    pub facet_ref: String,
50    /// The medium's name (the facet's `medium` field).
51    pub medium: String,
52    /// What kind of surface the medium references.
53    pub medium_type: MediumType,
54    /// Where the medium's body lives — path / URL / mem id, opaque here.
55    pub medium_pointer: String,
56    /// The medium's declared change-detection strategy, if any (`none` /
57    /// `git` / `mtime` / `auto`). Unset means `auto` — see
58    /// [`resolve_change_strategy`].
59    pub declared_change_detection: Option<String>,
60    /// The facet's allow/deny selection over the medium. A facet with **no
61    /// allow patterns is *unscoped*** — a typed refusal at run time (see
62    /// [`super::cursor`]'s empty-scope semantics), not "whole medium". A facet
63    /// that truly wants everything writes `**/*`.
64    pub scope: Vec<PatternEntry>,
65    /// A declared deterministic preparation step (e.g. `pdf-to-markdown`).
66    /// Unset for every text medium today; a set value is reported
67    /// unsupported at run time rather than run against raw content.
68    pub preparation: Option<String>,
69}
70
71/// A v1 binding joined to its resolved sources — the runtime shape the
72/// orchestration stages (cursor, brief, selection) consume.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ResolvedIngest {
75    /// The run's identity — the canonical binding id `<mem>/<stem>` (D3), the
76    /// string every downstream key (sync_state, selection cache, brief header)
77    /// derives from.
78    pub name: String,
79    /// The build mode — discovery / one-shot (`refinement` is deleted).
80    /// Defaults to discovery when the binding declares no `build` block.
81    pub mode: BuildMode,
82    /// Loop / manual / on-event.
83    pub trigger: IngestTrigger,
84    /// How many artifacts a single run processes.
85    pub batch_size: u32,
86    /// Paths excluded for this ingest's runs, on top of facet scope.
87    pub deny_paths: Vec<String>,
88    /// The projection reference verbatim (`"<mem>/<name>"`).
89    pub projection_ref: String,
90    /// The projection's owning mem (the part before the `/`).
91    pub projection_mem: String,
92    /// The projection's name (the part after the `/`).
93    pub projection_name: String,
94    /// The projection's intent — prose for the agent (the brief's "about
95    /// the source" block).
96    pub intent: Option<String>,
97    /// The resolved sources, in projection order: primary facets first
98    /// (in `source_facets` order), then reference mems.
99    pub sources: Vec<ResolvedSource>,
100    /// The single mem this ingest's projection writes into.
101    pub destination_mem: String,
102    /// Free-form projection rules (e.g. one-shot lens `routing`).
103    pub rules: Option<serde_json::Value>,
104    /// Free-form ingest post-run actions (e.g. one-shot `archive_source`).
105    pub post_actions: Option<serde_json::Value>,
106}
107
108/// Why [`resolve_ingest`] could not produce a [`ResolvedIngest`]. Every
109/// variant names the offending reference and, where useful, what *was*
110/// available — so a config typo is diagnosable without re-reading the store.
111#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112pub enum ResolveError {
113    /// No binding with the given id in the store.
114    #[error("binding '{name}' not found; available: {}", fmt_list(available))]
115    BindingNotFound {
116        /// The requested binding id.
117        name: String,
118        /// The binding ids that do exist.
119        available: Vec<String>,
120    },
121    /// The binding id is not the required `"<mem>/<name>"`.
122    #[error("malformed binding id '{projection}'; expected \"<mem>/<name>\"")]
123    MalformedProjectionRef {
124        /// The binding whose id is malformed.
125        ingest: String,
126        /// The malformed value.
127        projection: String,
128    },
129    /// A projection source facet does not exist in the projection's mem.
130    #[error(
131        "projection '{projection_ref}' references facet '{facet}' not found in mem '{mem}'; available: {}",
132        fmt_list(available)
133    )]
134    FacetNotFound {
135        /// The projection that references the missing facet.
136        projection_ref: String,
137        /// The missing facet name.
138        facet: String,
139        /// The mem the facet was looked up in.
140        mem: String,
141        /// The facet names that do exist in that mem.
142        available: Vec<String>,
143    },
144    /// A facet's medium does not exist in the projection's mem.
145    #[error(
146        "facet '{facet}' references medium '{medium}' not found in mem '{mem}'; available: {}",
147        fmt_list(available)
148    )]
149    MediumNotFound {
150        /// The facet whose medium is missing.
151        facet: String,
152        /// The missing medium name.
153        medium: String,
154        /// The mem the medium was looked up in.
155        mem: String,
156        /// The medium names that do exist in that mem.
157        available: Vec<String>,
158    },
159}
160
161/// Render a name list for an error message: `a, b, c` or `(none)`.
162fn fmt_list(names: &[String]) -> String {
163    if names.is_empty() {
164        "(none)".to_string()
165    } else {
166        names.join(", ")
167    }
168}
169
170/// Resolve a v1 [`BindingV1`]'s **primary** sources (facet + medium) against
171/// the loaded [`PipelineConfigs`], producing a [`ResolvedBinding`] ready for
172/// [`crate::binding::hash_binding`] / [`crate::binding::validate_binding`].
173///
174/// The binding replaces the gen-2 projection, so — unlike [`resolve_ingest`] —
175/// there is no projection record to look up: the binding *is* the declaration.
176/// `binding_id` is the canonical `<mem>/<stem>` (D3); its `<mem>` half is the
177/// tier the facets/mediums live under (the same mem `resolve_ingest` joins in).
178/// Reference mems are not resolved here (only primary facets carry capability
179/// constraints), matching [`crate::binding_migrate::resolve_migrated_binding`].
180///
181/// Every lookup failure is a located [`ResolveError`] (reusing the ingest
182/// resolver's variants); a malformed `binding_id` is
183/// [`ResolveError::MalformedProjectionRef`]. Pure — no I/O.
184pub fn resolve_binding(
185    configs: &PipelineConfigs,
186    binding_id: &str,
187    binding: &BindingV1,
188) -> Result<ResolvedBinding, ResolveError> {
189    let (mem, _name) = binding_id
190        .split_once('/')
191        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
192        .ok_or_else(|| ResolveError::MalformedProjectionRef {
193            ingest: binding_id.to_string(),
194            projection: binding_id.to_string(),
195        })?;
196
197    let mut primary_sources = Vec::with_capacity(binding.source_facets.len());
198    for facet_name in &binding.source_facets {
199        let facet: &Facet = configs
200            .facets
201            .iter()
202            .find(|r| r.mem == mem && r.name == *facet_name)
203            .map(|r| &r.config)
204            .ok_or_else(|| ResolveError::FacetNotFound {
205                projection_ref: binding_id.to_string(),
206                facet: facet_name.clone(),
207                mem: mem.to_string(),
208                available: configs
209                    .facets
210                    .iter()
211                    .filter(|r| r.mem == mem)
212                    .map(|r| r.name.clone())
213                    .collect(),
214            })?;
215
216        let medium: &Medium = configs
217            .mediums
218            .iter()
219            .find(|r| r.mem == mem && r.name == facet.medium)
220            .map(|r| &r.config)
221            .ok_or_else(|| ResolveError::MediumNotFound {
222                facet: facet_name.clone(),
223                medium: facet.medium.clone(),
224                mem: mem.to_string(),
225                available: configs
226                    .mediums
227                    .iter()
228                    .filter(|r| r.mem == mem)
229                    .map(|r| r.name.clone())
230                    .collect(),
231            })?;
232
233        primary_sources.push(ResolvedPrimarySource {
234            facet_ref: facet_name.clone(),
235            medium: facet.medium.clone(),
236            medium_type: medium.medium_type,
237            medium_pointer: medium.pointer.clone(),
238            declared_change_detection: medium.change_detection.clone(),
239            scope: facet.scope.clone(),
240            preparation: facet.preparation.clone(),
241        });
242    }
243
244    Ok(ResolvedBinding {
245        binding: binding.clone(),
246        primary_sources,
247    })
248}
249
250/// Resolve a **v1 binding** (by canonical id) into the runtime
251/// [`ResolvedIngest`] shape the orchestration stages (cursor, brief,
252/// selection) consume — the binding-era counterpart of [`resolve_ingest`].
253///
254/// The binding *is* the declaration (no projection record to look up, no flat
255/// ingest to join): its `operations.build` supplies the run mode / trigger /
256/// batch / post-actions, and `deny_paths` / `intent` / `rules` come straight
257/// off the binding. `binding_id` is the canonical `<mem>/<stem>` (D3), which
258/// becomes the resolved ingest's `name` and `projection_ref` — every downstream
259/// key (sync_state, selection cache, brief header) is derived from it.
260///
261/// The binding's `build.mode` is [`BuildMode::Discovery`] or
262/// [`BuildMode::OneShot`] (`refinement` is deleted from the vocabulary, D1) and
263/// becomes the resolved run's `mode` directly; an absent build block defaults
264/// to discovery. Facets and mediums are joined in the binding-id's `<mem>` tier.
265/// Every lookup failure is a located [`ResolveError`]; a malformed `binding_id`
266/// is [`ResolveError::MalformedProjectionRef`]. Pure — no I/O.
267pub fn resolve_binding_run(
268    configs: &BindingConfigs,
269    binding_id: &str,
270    binding: &BindingV1,
271) -> Result<ResolvedIngest, ResolveError> {
272    let (mem, name) = binding_id
273        .split_once('/')
274        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
275        .ok_or_else(|| ResolveError::MalformedProjectionRef {
276            ingest: binding_id.to_string(),
277            projection: binding_id.to_string(),
278        })?;
279    let mem = mem.to_string();
280    let name = name.to_string();
281
282    let mut sources =
283        Vec::with_capacity(binding.source_facets.len() + binding.reference_mems.len());
284    for facet_name in &binding.source_facets {
285        let facet: &Facet = configs
286            .facets
287            .iter()
288            .find(|r| r.mem == mem && r.name == *facet_name)
289            .map(|r| &r.config)
290            .ok_or_else(|| ResolveError::FacetNotFound {
291                projection_ref: binding_id.to_string(),
292                facet: facet_name.clone(),
293                mem: mem.clone(),
294                available: configs
295                    .facets
296                    .iter()
297                    .filter(|r| r.mem == mem)
298                    .map(|r| r.name.clone())
299                    .collect(),
300            })?;
301
302        let medium: &Medium = configs
303            .mediums
304            .iter()
305            .find(|r| r.mem == mem && r.name == facet.medium)
306            .map(|r| &r.config)
307            .ok_or_else(|| ResolveError::MediumNotFound {
308                facet: facet_name.clone(),
309                medium: facet.medium.clone(),
310                mem: mem.clone(),
311                available: configs
312                    .mediums
313                    .iter()
314                    .filter(|r| r.mem == mem)
315                    .map(|r| r.name.clone())
316                    .collect(),
317            })?;
318
319        sources.push(ResolvedSource::Primary(ResolvedPrimarySource {
320            facet_ref: facet_name.clone(),
321            medium: facet.medium.clone(),
322            medium_type: medium.medium_type,
323            medium_pointer: medium.pointer.clone(),
324            declared_change_detection: medium.change_detection.clone(),
325            scope: facet.scope.clone(),
326            preparation: facet.preparation.clone(),
327        }));
328    }
329    for reference_mem in &binding.reference_mems {
330        sources.push(ResolvedSource::Reference {
331            mem: reference_mem.clone(),
332        });
333    }
334
335    // The build op supplies mode / trigger / batch / post-actions. An absent
336    // build block (a not-yet-built obligation) resolves to sane defaults — the
337    // build-path refusal (D6/AC4) is enforced at the brief entry point, not
338    // here, so read-only callers (status) keep working.
339    let build = binding.operations.build.as_ref();
340    let mode = build.map_or(BuildMode::Discovery, |b| b.mode);
341    let trigger = build.map_or(IngestTrigger::Loop, |b| b.trigger);
342    let batch_size = build.map_or(20, |b| b.batch_size);
343    let post_actions = build.and_then(|b| b.post_actions.clone());
344
345    Ok(ResolvedIngest {
346        name: binding_id.to_string(),
347        mode,
348        trigger,
349        batch_size,
350        deny_paths: binding.deny_paths.clone(),
351        projection_ref: binding_id.to_string(),
352        projection_mem: mem,
353        projection_name: name,
354        intent: binding.intent.clone(),
355        sources,
356        destination_mem: binding.destination_mem.clone(),
357        rules: binding.rules.clone(),
358        post_actions,
359    })
360}
361
362/// A primary source's resolved change-detection strategy — how "what
363/// changed since the last synced pass" is computed for it.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum ChangeStrategy {
366    /// No change detection — the source is re-roamed whole (inert signal).
367    None,
368    /// Git-commit diff between the baseline commit and current `HEAD`.
369    Git,
370    /// Filesystem `(mtime, size)` stat-map digest + diff.
371    Mtime,
372    /// Graph snapshot diff (the source mem's snapshot token).
373    Graph,
374}
375
376/// Resolve a primary source's [`ChangeStrategy`], mirroring the plugin's
377/// `resolveChangeDetection`. A graph-typed medium always uses [`Graph`]
378/// (its signal is the source mem's snapshot token, which the engine
379/// provides). Otherwise the medium's declared strategy wins
380/// (`none`/`git`/`mtime`); `auto` — the default when unset, and any
381/// unrecognized value — probes for a git work tree over the medium pointer
382/// (resolved against `workspace_root`): present → [`Git`], absent →
383/// [`Mtime`].
384///
385/// [`Graph`]: ChangeStrategy::Graph
386/// [`Git`]: ChangeStrategy::Git
387/// [`Mtime`]: ChangeStrategy::Mtime
388pub fn resolve_change_strategy(
389    source: &ResolvedPrimarySource,
390    workspace_root: &Path,
391) -> ChangeStrategy {
392    if source.medium_type == MediumType::Graph {
393        return ChangeStrategy::Graph;
394    }
395    // A detection-less medium (per the D6 capability matrix — `web` this cycle)
396    // has no change signal: it resolves to the visible NoSignal (`none`), never
397    // a fabricated `mtime`/`git` token (E1 / AC16). This mirrors the graph
398    // special case above — the medium type overrides any declared value.
399    if !medium_capabilities(source.medium_type).change_signal {
400        return ChangeStrategy::None;
401    }
402    match source.declared_change_detection.as_deref() {
403        Some("none") => ChangeStrategy::None,
404        Some("git") => ChangeStrategy::Git,
405        Some("mtime") => ChangeStrategy::Mtime,
406        // `auto`, unset, or any unrecognized value: probe the filesystem.
407        _ => {
408            let base = if source.medium_pointer.is_empty() {
409                workspace_root.to_path_buf()
410            } else {
411                // `Path::join` yields the pointer verbatim when it is
412                // absolute, matching the plugin's `resolve(root, pointer)`.
413                workspace_root.join(&source.medium_pointer)
414            };
415            if find_git_root(&base).is_some() {
416                ChangeStrategy::Git
417            } else {
418                ChangeStrategy::Mtime
419            }
420        }
421    }
422}
423
424/// Walk up from `start` looking for a `.git` entry (a directory *or* a file
425/// — a submodule/worktree gitlink is a file), returning the directory that
426/// contains it (the git work-tree root), or `None`. Bounded to 64 ancestors.
427/// Pure filesystem, no subprocess — deterministic for tests.
428pub fn find_git_root(start: &Path) -> Option<PathBuf> {
429    let mut dir = start.to_path_buf();
430    for _ in 0..64 {
431        if dir.join(".git").exists() {
432            return Some(dir);
433        }
434        match dir.parent() {
435            Some(parent) => dir = parent.to_path_buf(),
436            None => break,
437        }
438    }
439    None
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::pipeline::PatternMode;
446    use crate::pipeline_store::MemPipelineRecord;
447
448    fn medium(mem: &str, name: &str, ty: MediumType, pointer: &str) -> MemPipelineRecord<Medium> {
449        MemPipelineRecord {
450            mem: mem.to_string(),
451            name: name.to_string(),
452            config: Medium {
453                name: name.to_string(),
454                medium_type: ty,
455                pointer: pointer.to_string(),
456                change_detection: None,
457            },
458        }
459    }
460
461    fn primary(
462        medium_type: MediumType,
463        pointer: &str,
464        declared: Option<&str>,
465    ) -> ResolvedPrimarySource {
466        ResolvedPrimarySource {
467            facet_ref: "f".to_string(),
468            medium: "m".to_string(),
469            medium_type,
470            medium_pointer: pointer.to_string(),
471            declared_change_detection: declared.map(str::to_string),
472            scope: vec![],
473            preparation: None,
474        }
475    }
476
477    fn facet(
478        mem: &str,
479        name: &str,
480        medium: &str,
481        scope: Vec<PatternEntry>,
482    ) -> MemPipelineRecord<Facet> {
483        MemPipelineRecord {
484            mem: mem.to_string(),
485            name: name.to_string(),
486            config: Facet {
487                name: name.to_string(),
488                medium: medium.to_string(),
489                scope,
490                engagement: None,
491                preparation: None,
492            },
493        }
494    }
495
496    fn allow(path: &str) -> PatternEntry {
497        PatternEntry {
498            path: path.to_string(),
499            mode: PatternMode::Allow,
500        }
501    }
502
503    fn v1_binding(dest: &str, facets: &[&str]) -> BindingV1 {
504        use crate::binding::{
505            BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
506        };
507        BindingV1 {
508            version: BINDING_VERSION,
509            intent: Some("prose".to_string()),
510            source_facets: facets.iter().map(|s| s.to_string()).collect(),
511            reference_mems: vec![],
512            destination_mem: dest.to_string(),
513            deny_paths: vec![],
514            coverage_semantics: CoverageSemantics::Exhaustive,
515            rules: None,
516            prune: None,
517            operations: Operations {
518                build: Some(BuildOperation {
519                    mode: BuildMode::Discovery,
520                    trigger: IngestTrigger::Loop,
521                    batch_size: 20,
522                    post_actions: None,
523                }),
524                sync: None,
525                verify: None,
526            },
527        }
528    }
529
530    /// `resolve_binding` joins each of the binding's source facets to the
531    /// medium it engages, in the binding-id's `<mem>` tier — no projection
532    /// record needed (the binding is the declaration).
533    #[test]
534    fn resolves_a_v1_binding() {
535        let configs = PipelineConfigs {
536            mediums: vec![medium("engine", "src", MediumType::Codebase, "../public")],
537            facets: vec![facet(
538                "engine",
539                "source-tree",
540                "src",
541                vec![allow("../public/**/*.rs")],
542            )],
543            ..Default::default()
544        };
545        let binding = v1_binding("engine", &["source-tree"]);
546        let resolved = resolve_binding(&configs, "engine/graph", &binding).unwrap();
547        assert_eq!(resolved.primary_sources.len(), 1);
548        let p = &resolved.primary_sources[0];
549        assert_eq!(p.facet_ref, "source-tree");
550        assert_eq!(p.medium, "src");
551        assert_eq!(p.medium_type, MediumType::Codebase);
552        assert_eq!(p.medium_pointer, "../public");
553        assert_eq!(p.scope, vec![allow("../public/**/*.rs")]);
554    }
555
556    /// A binding whose source facet does not exist in its mem errors, located.
557    #[test]
558    fn resolve_binding_dangling_facet_errors() {
559        let configs = PipelineConfigs::default();
560        let binding = v1_binding("engine", &["missing-facet"]);
561        let err = resolve_binding(&configs, "engine/graph", &binding).unwrap_err();
562        assert!(matches!(
563            err,
564            ResolveError::FacetNotFound { ref facet, .. } if facet == "missing-facet"
565        ));
566    }
567
568    /// A malformed binding id (no `/`) is a located error.
569    #[test]
570    fn resolve_binding_malformed_id_errors() {
571        let configs = PipelineConfigs::default();
572        let binding = v1_binding("engine", &[]);
573        let err = resolve_binding(&configs, "noslash", &binding).unwrap_err();
574        assert!(matches!(err, ResolveError::MalformedProjectionRef { .. }));
575    }
576
577    /// `resolve_binding_run` produces the runtime shape from a binding: the id
578    /// becomes `name`/`projection_ref`, `build.mode` maps to the run mode,
579    /// deny_paths / trigger / batch / post_actions come off the build op, and
580    /// each facet joins its medium; reference mems follow the primaries.
581    #[test]
582    fn resolve_binding_run_produces_runtime_shape() {
583        use crate::binding::{
584            BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
585        };
586        let configs = BindingConfigs {
587            mediums: vec![MemPipelineRecord {
588                mem: "app".to_string(),
589                name: "src".to_string(),
590                config: Medium {
591                    name: "src".to_string(),
592                    medium_type: MediumType::Codebase,
593                    pointer: "../app".to_string(),
594                    change_detection: None,
595                },
596            }],
597            facets: vec![MemPipelineRecord {
598                mem: "app".to_string(),
599                name: "source-tree".to_string(),
600                config: Facet {
601                    name: "source-tree".to_string(),
602                    medium: "src".to_string(),
603                    scope: vec![allow("../app/**/*.swift")],
604                    engagement: None,
605                    preparation: None,
606                },
607            }],
608            bindings: vec![],
609        };
610        let binding = BindingV1 {
611            version: BINDING_VERSION,
612            intent: Some("swift".to_string()),
613            source_facets: vec!["source-tree".to_string()],
614            reference_mems: vec!["engine".to_string()],
615            destination_mem: "app".to_string(),
616            deny_paths: vec!["**/VISION.md".to_string()],
617            coverage_semantics: CoverageSemantics::Exhaustive,
618            rules: None,
619            prune: None,
620            operations: Operations {
621                build: Some(BuildOperation {
622                    mode: BuildMode::Discovery,
623                    trigger: IngestTrigger::Loop,
624                    batch_size: 20,
625                    post_actions: Some(serde_json::json!({ "archive_source": true })),
626                }),
627                sync: None,
628                verify: None,
629            },
630        };
631
632        let r = resolve_binding_run(&configs, "app/graph", &binding).unwrap();
633        assert_eq!(r.name, "app/graph");
634        assert_eq!(r.projection_ref, "app/graph");
635        assert_eq!(r.projection_mem, "app");
636        assert_eq!(r.projection_name, "graph");
637        assert_eq!(r.mode, BuildMode::Discovery);
638        assert_eq!(r.batch_size, 20);
639        assert_eq!(r.deny_paths, ["**/VISION.md"]);
640        assert_eq!(r.destination_mem, "app");
641        assert_eq!(r.intent.as_deref(), Some("swift"));
642        assert_eq!(
643            r.post_actions,
644            Some(serde_json::json!({ "archive_source": true }))
645        );
646        assert_eq!(r.sources.len(), 2);
647        match &r.sources[0] {
648            ResolvedSource::Primary(p) => {
649                assert_eq!(p.facet_ref, "source-tree");
650                assert_eq!(p.medium_type, MediumType::Codebase);
651                assert_eq!(p.medium_pointer, "../app");
652            }
653            other => panic!("expected primary first, got {other:?}"),
654        }
655        assert_eq!(
656            r.sources[1],
657            ResolvedSource::Reference {
658                mem: "engine".to_string()
659            }
660        );
661    }
662
663    /// A one-shot binding maps to the one-shot run mode.
664    #[test]
665    fn resolve_binding_run_maps_one_shot() {
666        use crate::binding::{
667            BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
668        };
669        let configs = BindingConfigs::default();
670        let binding = BindingV1 {
671            version: BINDING_VERSION,
672            intent: None,
673            source_facets: vec![],
674            reference_mems: vec![],
675            destination_mem: "m".to_string(),
676            deny_paths: vec![],
677            coverage_semantics: CoverageSemantics::Exhaustive,
678            rules: None,
679            prune: None,
680            operations: Operations {
681                build: Some(BuildOperation {
682                    mode: BuildMode::OneShot,
683                    trigger: IngestTrigger::Manual,
684                    batch_size: 5,
685                    post_actions: None,
686                }),
687                sync: None,
688                verify: None,
689            },
690        };
691        let r = resolve_binding_run(&configs, "m/lens", &binding).unwrap();
692        assert_eq!(r.mode, BuildMode::OneShot);
693    }
694
695    /// A graph-typed medium always resolves to the graph strategy,
696    /// regardless of any declared value.
697    #[test]
698    fn graph_medium_always_uses_graph_strategy() {
699        let root = Path::new("/nonexistent");
700        assert_eq!(
701            resolve_change_strategy(&primary(MediumType::Graph, "", None), root),
702            ChangeStrategy::Graph
703        );
704        // Even a declared override does not change a graph medium.
705        assert_eq!(
706            resolve_change_strategy(&primary(MediumType::Graph, "", Some("mtime")), root),
707            ChangeStrategy::Graph
708        );
709    }
710
711    /// A `web` medium (detection-less per the D6 matrix) resolves to the
712    /// visible NoSignal `None` regardless of any declared value — `status`
713    /// renders `signal: none`, never a fabricated `mtime`/`git` token (AC16).
714    #[test]
715    fn web_medium_resolves_to_none_signal() {
716        let root = Path::new("/nonexistent");
717        assert_eq!(
718            resolve_change_strategy(&primary(MediumType::Web, "https://example.com", None), root),
719            ChangeStrategy::None
720        );
721        // Even a declared override does not fabricate a signal for web.
722        assert_eq!(
723            resolve_change_strategy(
724                &primary(MediumType::Web, "https://example.com", Some("mtime")),
725                root
726            ),
727            ChangeStrategy::None
728        );
729    }
730
731    /// A declared `none`/`git`/`mtime` wins for a non-graph medium.
732    #[test]
733    fn declared_strategy_wins_for_non_graph() {
734        let root = Path::new("/nonexistent");
735        for (declared, expected) in [
736            ("none", ChangeStrategy::None),
737            ("git", ChangeStrategy::Git),
738            ("mtime", ChangeStrategy::Mtime),
739        ] {
740            assert_eq!(
741                resolve_change_strategy(&primary(MediumType::Codebase, "x", Some(declared)), root),
742                expected,
743                "declared '{declared}'"
744            );
745        }
746    }
747
748    /// `auto` (unset, or an unrecognized value) probes the filesystem: a
749    /// pointer under a git work tree → git, otherwise → mtime.
750    #[test]
751    fn auto_probes_for_a_git_work_tree() {
752        let git = tempfile::tempdir().unwrap();
753        std::fs::create_dir(git.path().join(".git")).unwrap();
754        std::fs::create_dir(git.path().join("sub")).unwrap();
755        let plain = tempfile::tempdir().unwrap();
756
757        // Unset → auto → probe. Pointer resolves under the git root → Git.
758        assert_eq!(
759            resolve_change_strategy(&primary(MediumType::Codebase, "sub", None), git.path()),
760            ChangeStrategy::Git
761        );
762        // An unrecognized declared value also falls through to the probe.
763        assert_eq!(
764            resolve_change_strategy(
765                &primary(MediumType::Codebase, "sub", Some("weird")),
766                git.path()
767            ),
768            ChangeStrategy::Git
769        );
770        // No git work tree over the pointer → Mtime.
771        assert_eq!(
772            resolve_change_strategy(&primary(MediumType::Filesystem, ".", None), plain.path()),
773            ChangeStrategy::Mtime
774        );
775    }
776
777    /// `find_git_root` returns the containing directory of a `.git` entry,
778    /// walking up from a nested start, and `None` when there is none.
779    #[test]
780    fn find_git_root_walks_up() {
781        let root = tempfile::tempdir().unwrap();
782        std::fs::create_dir(root.path().join(".git")).unwrap();
783        let nested = root.path().join("a/b/c");
784        std::fs::create_dir_all(&nested).unwrap();
785
786        assert_eq!(
787            find_git_root(&nested).as_deref(),
788            Some(root.path()),
789            "walks up to the work-tree root"
790        );
791
792        let plain = tempfile::tempdir().unwrap();
793        assert_eq!(find_git_root(plain.path()), None, "no .git anywhere above");
794    }
795}