Skip to main content

spec_driven_docs/plan/
observe.rs

1//! What the planner is told, and the one place that reads it.
2//!
3//! The planner is pure, so everything it needs arrives as a value: the
4//! repository, the installation, the host, and the corpus. This module is
5//! the impure half that produces those values, and it is the only part of
6//! the plan tree that touches a disk.
7//!
8//! Reading is bounded. An observation that cannot be made is recorded as
9//! not observed, with the reason, so the readiness policy can weigh it.
10//! An observation whose failure has no bound — an unreadable target — is a
11//! command error rather than a plan built on a guess.
12
13use std::collections::BTreeMap;
14
15use camino::{Utf8Path, Utf8PathBuf};
16use serde::{Deserialize, Serialize};
17
18use crate::domain::instance_config::CONFIG_PATH;
19use crate::domain::manifest::{INSTANCE_DIR, MANIFEST_PATH};
20use crate::domain::ownership::Sha256;
21use crate::domain::paths::UserEnv;
22use crate::domain::profile::{DocsRoot, ProfileId};
23use crate::domain::version::CanonVersion;
24use crate::error::AppError;
25use crate::plan::finding::{is_ordinal_name, is_record_shaped, is_spec_shaped};
26use crate::plan::operation::TargetPath;
27
28/// The documentation roots a corpus conventionally lives under.
29const DOC_ROOTS: &[&str] = &["docs", "_docs", "doc", "documentation"];
30
31/// Root-level filename stems that are metadata rather than a corpus.
32const ROOT_METADATA: &[&str] = &[
33    "readme",
34    "license",
35    "licence",
36    "contributing",
37    "changelog",
38    "agents",
39    "claude",
40    "code_of_conduct",
41];
42
43/// Directory names no observation walks into.
44const SKIPPED: &[&str] = &[".git", ".jj", "target", "node_modules", INSTANCE_DIR];
45
46/// Extensions a durable document conventionally carries.
47const DOC_EXTENSIONS: &[&str] = &["md", "markdown", "adoc", "rst", "org"];
48
49/// The target itself.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Repository {
52    /// Where it is.
53    pub root: Utf8PathBuf,
54    /// Whether version control is there to restore a retirement.
55    pub version_controlled: bool,
56    /// Whether it holds anything at all.
57    pub empty: bool,
58}
59
60/// One file the instance records.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct RecordedFile {
63    /// Where, relative to the target.
64    pub path: TargetPath,
65    /// What the record says the tool wrote there.
66    pub recorded: Sha256,
67    /// What the baseline was, for an adopted file.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub baseline: Option<Sha256>,
70    /// What the target holds now, or nothing where the file is gone.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub held: Option<Sha256>,
73}
74
75/// What is installed at the target.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Installation {
78    /// The release the record names.
79    pub canon_version: CanonVersion,
80    /// The profile the record names.
81    pub profile: ProfileId,
82    /// The documentation root the record names.
83    pub docs_root: DocsRoot,
84    /// The record's own digest.
85    pub record_sha256: Sha256,
86    /// The project's declaration digest, where it has one.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub declaration_sha256: Option<Sha256>,
89    /// Every managed file, as recorded and as found.
90    pub managed: Vec<RecordedFile>,
91    /// Every adopted file, as recorded and as found.
92    pub adopted: Vec<RecordedFile>,
93    /// Every marked region the canon owns, as recorded and as found.
94    pub blocks: Vec<RecordedBlock>,
95}
96
97/// One marked region a file the project owns carries.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RecordedBlock {
100    /// The host file, relative to the target.
101    pub path: TargetPath,
102    /// The region's digest as the record has it.
103    pub recorded: Sha256,
104    /// The region's digest now, or nothing where the host or the markers
105    /// are gone.
106    pub held: Option<Sha256>,
107}
108
109impl Installation {
110    /// Whether any recorded managed file has moved or gone.
111    #[must_use]
112    pub fn drifted(&self) -> bool {
113        self.managed
114            .iter()
115            .any(|file| file.held.as_ref() != Some(&file.recorded))
116            || self
117                .blocks
118                .iter()
119                .any(|block| block.held.as_ref() != Some(&block.recorded))
120    }
121}
122
123/// The host the command ran on.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Host {
126    /// Whether this run may reach the network.
127    pub offline: bool,
128    /// Where this tool keeps what it can fetch again.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub cache_root: Option<Utf8PathBuf>,
131}
132
133/// What the corpus looks like, as counts and paths a detector reads.
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Corpus {
136    /// Documentation roots at the top level that hold anything.
137    pub populated_doc_roots: Vec<String>,
138    /// Every durable document, relative to the target, sorted.
139    pub documents: Vec<TargetPath>,
140    /// Documents shaped like a specification of this convention.
141    pub spec_shaped: Vec<TargetPath>,
142    /// Specification-shaped documents that define no rule identifier.
143    pub spec_without_rule_id: Vec<TargetPath>,
144    /// Documents named by their position rather than their subject.
145    pub ordinal_named: Vec<TargetPath>,
146    /// Decision records outside a decisions directory.
147    pub records_outside_decisions: Vec<TargetPath>,
148    /// Whether a specifications directory exists under a documentation root.
149    pub has_specs_directory: bool,
150}
151
152impl Corpus {
153    /// Whether the target documents itself already.
154    ///
155    /// Documents, not directories. A documentation root holding empty
156    /// directories is a layout somebody started and not a corpus somebody
157    /// wrote, and refusing to land beside it would refuse a repository
158    /// that has written nothing.
159    #[must_use]
160    pub const fn settled(&self) -> bool {
161        !self.documents.is_empty()
162    }
163}
164
165/// Everything the planner is told about one target.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Observation {
168    /// The target itself.
169    pub repository: Repository,
170    /// What is installed, where anything is.
171    pub installation: Option<Installation>,
172    /// Why no installation was read, where metadata exists and is broken.
173    pub invalid: Option<String>,
174    /// The host.
175    pub host: Host,
176    /// The corpus.
177    pub corpus: Corpus,
178}
179
180/// Read one target.
181///
182/// # Errors
183///
184/// [`AppError::Usage`] when the target is not a directory, and
185/// [`AppError::Io`] when the walk cannot complete. Both are failures whose
186/// scope cannot be bounded: a plan built on half a reading would describe
187/// a repository nobody looked at.
188pub fn observe(target: &Utf8Path) -> Result<Observation, AppError> {
189    match std::fs::metadata(target) {
190        Ok(metadata) if !metadata.is_dir() => {
191            return Err(AppError::Usage(format!(
192                "target is not a directory: {target}"
193            )));
194        }
195        Ok(_) => {}
196        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
197            return Err(AppError::Usage(format!("unresolved target: {target}")));
198        }
199        Err(error) => return Err(AppError::Io(error)),
200    }
201
202    let env = UserEnv::from_process();
203    let host = Host {
204        offline: crate::domain::paths::variable(crate::domain::paths::OFFLINE_VAR).is_some(),
205        cache_root: env.user_paths().map(|paths| paths.bundle_cache.path),
206    };
207
208    let (installation, invalid) = read_installation(target);
209    let corpus = read_corpus(target)?;
210    let repository = Repository {
211        root: target.to_owned(),
212        version_controlled: target.join(".git").exists(),
213        empty: is_empty(target)?,
214    };
215    Ok(Observation {
216        repository,
217        installation,
218        invalid,
219        host,
220        corpus,
221    })
222}
223
224/// Whether a target holds anything but version control.
225fn is_empty(target: &Utf8Path) -> Result<bool, AppError> {
226    for entry in std::fs::read_dir(target)? {
227        let entry = entry?;
228        let name = entry.file_name().to_string_lossy().to_string();
229        if name != ".git" && name != ".jj" {
230            return Ok(false);
231        }
232    }
233    Ok(true)
234}
235
236/// Read the instance record, or say why it could not be trusted.
237///
238/// Every failure here is one of the two answers rather than an error: a
239/// record that is absent and one that is broken are both things the plan
240/// reports, and neither stops the observation.
241fn read_installation(target: &Utf8Path) -> (Option<Installation>, Option<String>) {
242    let path = target.join(MANIFEST_PATH);
243    let Ok(text) = std::fs::read_to_string(&path) else {
244        return (None, None);
245    };
246    // The record's schema is its own axis. A record an older release wrote
247    // is a record this engine reads well enough to classify: what it needs
248    // is the version, the profile, the root, and the two file lists, and
249    // every schema this tool has written carries those under those names.
250    // Only a record it cannot read at all is invalid, because absence and
251    // breakage are different findings.
252    let Some(manifest) = read_any_schema(&text) else {
253        return (
254            None,
255            Some(format!(
256                "{MANIFEST_PATH} is not a record this engine can read"
257            )),
258        );
259    };
260    let held = |destination: &str| -> Option<Sha256> {
261        std::fs::read(target.join(destination))
262            .ok()
263            .map(|bytes| Sha256::of(&bytes))
264    };
265    let mut managed = Vec::new();
266    for (destination, recorded) in &manifest.managed_files {
267        let Ok(path) = TargetPath::new(destination) else {
268            return (
269                None,
270                Some(format!(
271                    "{MANIFEST_PATH} records the destination {destination}, which no operation may name"
272                )),
273            );
274        };
275        managed.push(RecordedFile {
276            held: held(destination),
277            path,
278            recorded: recorded.clone(),
279            baseline: None,
280        });
281    }
282    let mut adopted = Vec::new();
283    for (destination, recorded, baseline) in &manifest.adopted_files {
284        let Ok(path) = TargetPath::new(destination) else {
285            return (
286                None,
287                Some(format!(
288                    "{MANIFEST_PATH} records the destination {destination}, which no operation may name"
289                )),
290            );
291        };
292        adopted.push(RecordedFile {
293            held: held(destination),
294            path,
295            recorded: recorded.clone(),
296            baseline: Some(baseline.clone()),
297        });
298    }
299    // A marked region is managed too. The host file is the project's and
300    // its other bytes are none of this tool's business, so the region is
301    // compared by its own hash: an edit inside the markers is a conflict
302    // the next landing would overwrite, and an edit outside them is not.
303    let mut blocks = Vec::new();
304    for (host, recorded) in &manifest.integration_blocks {
305        let Ok(path) = TargetPath::new(host) else {
306            return (
307                None,
308                Some(format!(
309                    "{MANIFEST_PATH} records the block host {host}, which no operation may name"
310                )),
311            );
312        };
313        let (begin, end) = markers_for(host);
314        let held = std::fs::read_to_string(target.join(host))
315            .ok()
316            .and_then(|text| crate::domain::marker::block_hash_with(&text, begin, end));
317        blocks.push(RecordedBlock {
318            path,
319            recorded: recorded.clone(),
320            held,
321        });
322    }
323
324    let declaration_sha256 = std::fs::read(target.join(CONFIG_PATH))
325        .ok()
326        .map(|bytes| Sha256::of(&bytes));
327    (
328        Some(Installation {
329            canon_version: manifest.canon_version,
330            profile: manifest.profile,
331            docs_root: manifest.docs_root,
332            record_sha256: Sha256::of(text.as_bytes()),
333            declaration_sha256,
334            managed,
335            adopted,
336            blocks,
337        }),
338        None,
339    )
340}
341
342/// The markers one integration host carries.
343fn markers_for(path: &str) -> (&'static str, &'static str) {
344    use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
345    if path == crate::domain::paths::HOOKS_CONFIG_PATH {
346        (BEGIN, END)
347    } else {
348        (AGENTS_BEGIN, AGENTS_END)
349    }
350}
351
352/// One record's facts, whichever schema wrote it.
353struct AnyRecord {
354    canon_version: CanonVersion,
355    profile: ProfileId,
356    docs_root: DocsRoot,
357    managed_files: Vec<(String, Sha256)>,
358    adopted_files: Vec<(String, Sha256, Sha256)>,
359    integration_blocks: Vec<(String, Sha256)>,
360}
361
362fn read_any_schema(text: &str) -> Option<AnyRecord> {
363    let held: serde_json::Value = serde_json::from_str(text).ok()?;
364    let files = |key: &str| -> Vec<serde_json::Value> {
365        held.get(key)
366            .and_then(|value| value.as_array())
367            .cloned()
368            .unwrap_or_default()
369    };
370    let digest = |entry: &serde_json::Value, key: &str| -> Option<Sha256> {
371        entry.get(key)?.as_str()?.parse().ok()
372    };
373    let destination = |entry: &serde_json::Value| -> Option<String> {
374        Some(entry.get("destination")?.as_str()?.to_string())
375    };
376    Some(AnyRecord {
377        canon_version: held.get("canon_version")?.as_str()?.parse().ok()?,
378        profile: serde_json::from_value(held.get("profile")?.clone()).ok()?,
379        docs_root: serde_json::from_value(held.get("docs_root")?.clone()).ok()?,
380        managed_files: files("managed_files")
381            .iter()
382            .filter_map(|entry| Some((destination(entry)?, digest(entry, "sha256")?)))
383            .collect(),
384        // All or nothing. An entry this engine cannot read is a record it
385        // cannot vouch for, and dropping it would report a managed region
386        // as absent, which is what lets the next landing overwrite it.
387        integration_blocks: files("integration_blocks")
388            .iter()
389            .map(|entry| {
390                Some((
391                    entry.get("path")?.as_str()?.to_string(),
392                    digest(entry, "marker_hash")?,
393                ))
394            })
395            .collect::<Option<Vec<_>>>()?,
396        adopted_files: files("adopted_files")
397            .iter()
398            .filter_map(|entry| {
399                Some((
400                    destination(entry)?,
401                    digest(entry, "sha256")?,
402                    digest(entry, "baseline_sha256")?,
403                ))
404            })
405            .collect(),
406    })
407}
408
409/// Walk the corpus, reading only what a detector can prove.
410fn read_corpus(target: &Utf8Path) -> Result<Corpus, AppError> {
411    let mut corpus = Corpus::default();
412    for root in DOC_ROOTS {
413        let path = target.join(root);
414        if path.is_dir() && std::fs::read_dir(&path)?.next().is_some() {
415            corpus.populated_doc_roots.push((*root).to_string());
416        }
417        if path.join("specs").is_dir() {
418            corpus.has_specs_directory = true;
419        }
420    }
421
422    for entry in walkdir::WalkDir::new(target)
423        .into_iter()
424        .filter_entry(|entry| {
425            entry.depth() == 0
426                || !entry.file_type().is_dir()
427                || !SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref())
428        })
429    {
430        let entry = entry.map_err(|source| AppError::Io(std::io::Error::from(source)))?;
431        if !entry.file_type().is_file() {
432            continue;
433        }
434        let Ok(path) = Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) else {
435            continue;
436        };
437        let Ok(relative) = path.strip_prefix(target) else {
438            continue;
439        };
440        let Ok(held) = TargetPath::new(relative.as_str()) else {
441            continue;
442        };
443        let name = relative.file_name().unwrap_or_default();
444        let extension = relative.extension().unwrap_or_default();
445        if !DOC_EXTENSIONS.contains(&extension) {
446            continue;
447        }
448        // Root metadata is what every repository carries, whether or not
449        // it documents itself.
450        let stem = relative.file_stem().unwrap_or_default().to_lowercase();
451        if relative
452            .parent()
453            .is_none_or(|parent| parent.as_str().is_empty())
454            && ROOT_METADATA.contains(&stem.as_str())
455        {
456            continue;
457        }
458        corpus.documents.push(held.clone());
459        if is_spec_shaped(name) {
460            corpus.spec_shaped.push(held.clone());
461            let text = std::fs::read_to_string(&path).unwrap_or_default();
462            if crate::embedded::rule_ids_in(&text).next().is_none() {
463                corpus.spec_without_rule_id.push(held.clone());
464            }
465        }
466        if is_ordinal_name(name) {
467            corpus.ordinal_named.push(held.clone());
468        }
469        if is_record_shaped(name)
470            && relative
471                .parent()
472                .is_none_or(|parent| parent.file_name() != Some("decisions"))
473        {
474            corpus.records_outside_decisions.push(held);
475        }
476    }
477    corpus.documents.sort();
478    corpus.spec_shaped.sort();
479    corpus.spec_without_rule_id.sort();
480    corpus.ordinal_named.sort();
481    corpus.records_outside_decisions.sort();
482    Ok(corpus)
483}
484
485/// What the recorded destinations hold, keyed by path.
486#[must_use]
487pub fn held_by_path(installation: Option<&Installation>) -> BTreeMap<String, Sha256> {
488    let mut held = BTreeMap::new();
489    let Some(installation) = installation else {
490        return held;
491    };
492    for file in installation.managed.iter().chain(&installation.adopted) {
493        if let Some(digest) = file.held.clone() {
494            held.insert(file.path.as_str().to_string(), digest);
495        }
496    }
497    held
498}
499
500#[cfg(test)]
501mod tests {
502    #![allow(
503        clippy::unwrap_used,
504        reason = "a test panics as its failure signal, not as control flow"
505    )]
506
507    use super::*;
508
509    fn scratch() -> (tempfile::TempDir, Utf8PathBuf) {
510        let dir = tempfile::tempdir().unwrap();
511        let root = Utf8PathBuf::from(dir.path().to_str().unwrap());
512        std::fs::create_dir(root.join(".git")).unwrap();
513        (dir, root)
514    }
515
516    #[test]
517    fn an_empty_target_reads_as_empty_and_unsettled() {
518        let (_dir, root) = scratch();
519        let held = observe(&root).unwrap();
520        assert!(held.repository.empty);
521        assert!(held.repository.version_controlled);
522        assert!(held.installation.is_none());
523        assert_eq!(held.invalid, None);
524        assert!(!held.corpus.settled());
525    }
526
527    #[test]
528    fn root_metadata_is_not_a_corpus() {
529        let (_dir, root) = scratch();
530        std::fs::write(root.join("README.md"), "# x\n").unwrap();
531        std::fs::write(root.join("CHANGELOG.md"), "# x\n").unwrap();
532        let held = observe(&root).unwrap();
533        assert!(!held.repository.empty);
534        assert!(!held.corpus.settled(), "{:?}", held.corpus.documents);
535    }
536
537    #[test]
538    fn a_populated_documentation_root_is_a_settled_corpus() {
539        let (_dir, root) = scratch();
540        crate::adapters::fs::write_file(&root.join("docs/guide.md"), b"# guide\n").unwrap();
541        let held = observe(&root).unwrap();
542        assert_eq!(held.corpus.populated_doc_roots, ["docs"]);
543        assert!(held.corpus.settled());
544        assert_eq!(held.corpus.documents.len(), 1);
545    }
546
547    #[test]
548    fn a_documentation_root_of_empty_directories_is_not_a_corpus() {
549        let (_dir, root) = scratch();
550        std::fs::create_dir_all(root.join("_docs/specs")).unwrap();
551        let held = observe(&root).unwrap();
552        assert_eq!(held.corpus.populated_doc_roots, ["_docs"]);
553        assert!(!held.corpus.settled(), "a layout is not a corpus");
554    }
555
556    #[test]
557    fn the_corpus_reads_only_what_a_detector_can_prove() {
558        let (_dir, root) = scratch();
559        crate::adapters::fs::write_file(&root.join("docs/specs/SPEC-x.md"), b"# x\n").unwrap();
560        crate::adapters::fs::write_file(&root.join("docs/specs/SPEC-y.md"), b"### `a-b:c-d` - t\n")
561            .unwrap();
562        crate::adapters::fs::write_file(&root.join("docs/01-intro.md"), b"# x\n").unwrap();
563        crate::adapters::fs::write_file(&root.join("docs/ADR-a-choice.md"), b"# x\n").unwrap();
564        crate::adapters::fs::write_file(&root.join("docs/decisions/ADR-b-choice.md"), b"# x\n")
565            .unwrap();
566        crate::adapters::fs::write_file(&root.join("docs/specs/notes.md"), b"# x\n").unwrap();
567
568        let corpus = observe(&root).unwrap().corpus;
569        assert!(corpus.has_specs_directory);
570        assert_eq!(corpus.spec_shaped.len(), 2);
571        assert_eq!(
572            corpus
573                .spec_without_rule_id
574                .iter()
575                .map(TargetPath::as_str)
576                .collect::<Vec<_>>(),
577            ["docs/specs/SPEC-x.md"]
578        );
579        assert_eq!(
580            corpus
581                .ordinal_named
582                .iter()
583                .map(TargetPath::as_str)
584                .collect::<Vec<_>>(),
585            ["docs/01-intro.md"]
586        );
587        assert_eq!(
588            corpus
589                .records_outside_decisions
590                .iter()
591                .map(TargetPath::as_str)
592                .collect::<Vec<_>>(),
593            ["docs/ADR-a-choice.md"]
594        );
595    }
596
597    #[test]
598    fn a_record_that_does_not_parse_is_invalid_and_never_absent() {
599        let (_dir, root) = scratch();
600        crate::adapters::fs::write_file(&root.join(MANIFEST_PATH), b"{not json").unwrap();
601        let held = observe(&root).unwrap();
602        assert!(held.installation.is_none());
603        assert!(held.invalid.is_some(), "a broken record read as absent");
604    }
605
606    #[test]
607    fn an_unreadable_target_is_a_command_error_and_not_a_plan() {
608        let (_dir, root) = scratch();
609        std::fs::write(root.join("a-file"), b"x").unwrap();
610        let error = observe(&root.join("a-file")).unwrap_err();
611        assert_eq!(error.exit_code(), 64);
612        assert!(observe(&root.join("absent")).is_err());
613    }
614}