Skip to main content

spec_driven_docs/landing/
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::landing::finding::{is_ordinal_name, is_record_shaped, is_spec_shaped};
26use crate::landing::path::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.cache_root.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 text = match std::fs::read_to_string(&path) {
244        Ok(text) => text,
245        // Absent is absent. Anything else — unreadable, not UTF-8, a
246        // permission refusal — is a record that exists and cannot be
247        // trusted, and reporting it as absence would land seeds over it.
248        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return (None, None),
249        Err(source) => {
250            return (
251                None,
252                Some(format!("{path} exists and cannot be read: {source}")),
253            );
254        }
255    };
256    // The record's schema is its own axis. A record an older release wrote
257    // is a record this engine reads well enough to classify: what it needs
258    // is the version, the profile, the root, and the two file lists, and
259    // every schema this tool has written carries those under those names.
260    // Only a record it cannot read at all is invalid, because absence and
261    // breakage are different findings.
262    let Some(manifest) = read_any_schema(&text) else {
263        return (
264            None,
265            Some(format!(
266                "{MANIFEST_PATH} is not a record this engine can read"
267            )),
268        );
269    };
270    let held = |destination: &str| -> Option<Sha256> {
271        std::fs::read(target.join(destination))
272            .ok()
273            .map(|bytes| Sha256::of(&bytes))
274    };
275    let mut managed = Vec::new();
276    for (destination, recorded) in &manifest.managed_files {
277        let Ok(path) = TargetPath::new(destination) else {
278            return (
279                None,
280                Some(format!(
281                    "{MANIFEST_PATH} records the destination {destination}, which no operation may name"
282                )),
283            );
284        };
285        managed.push(RecordedFile {
286            held: held(destination),
287            path,
288            recorded: recorded.clone(),
289            baseline: None,
290        });
291    }
292    let mut adopted = Vec::new();
293    for (destination, recorded, baseline) in &manifest.adopted_files {
294        let Ok(path) = TargetPath::new(destination) else {
295            return (
296                None,
297                Some(format!(
298                    "{MANIFEST_PATH} records the destination {destination}, which no operation may name"
299                )),
300            );
301        };
302        adopted.push(RecordedFile {
303            held: held(destination),
304            path,
305            recorded: recorded.clone(),
306            baseline: Some(baseline.clone()),
307        });
308    }
309    // A marked region is managed too. The host file is the project's and
310    // its other bytes are none of this tool's business, so the region is
311    // compared by its own hash: an edit inside the markers is a conflict
312    // the next landing would overwrite, and an edit outside them is not.
313    let mut blocks = Vec::new();
314    for (host, recorded) in &manifest.integration_blocks {
315        let Ok(path) = TargetPath::new(host) else {
316            return (
317                None,
318                Some(format!(
319                    "{MANIFEST_PATH} records the block host {host}, which no operation may name"
320                )),
321            );
322        };
323        let (begin, end) = markers_for(host);
324        let held = std::fs::read_to_string(target.join(host))
325            .ok()
326            .and_then(|text| crate::domain::marker::block_hash_with(&text, begin, end));
327        blocks.push(RecordedBlock {
328            path,
329            recorded: recorded.clone(),
330            held,
331        });
332    }
333
334    let declaration_sha256 = std::fs::read(target.join(CONFIG_PATH))
335        .ok()
336        .map(|bytes| Sha256::of(&bytes));
337    (
338        Some(Installation {
339            canon_version: manifest.canon_version,
340            profile: manifest.profile,
341            docs_root: manifest.docs_root,
342            record_sha256: Sha256::of(text.as_bytes()),
343            declaration_sha256,
344            managed,
345            adopted,
346            blocks,
347        }),
348        None,
349    )
350}
351
352/// The markers one integration host carries.
353fn markers_for(path: &str) -> (&'static str, &'static str) {
354    use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
355    if path == crate::domain::paths::HOOKS_CONFIG_PATH {
356        (BEGIN, END)
357    } else {
358        (AGENTS_BEGIN, AGENTS_END)
359    }
360}
361
362/// One record's facts, whichever schema wrote it.
363struct AnyRecord {
364    canon_version: CanonVersion,
365    profile: ProfileId,
366    docs_root: DocsRoot,
367    managed_files: Vec<(String, Sha256)>,
368    adopted_files: Vec<(String, Sha256, Sha256)>,
369    integration_blocks: Vec<(String, Sha256)>,
370}
371
372fn read_any_schema(text: &str) -> Option<AnyRecord> {
373    let held: serde_json::Value = serde_json::from_str(text).ok()?;
374    let files = |key: &str| -> Vec<serde_json::Value> {
375        held.get(key)
376            .and_then(|value| value.as_array())
377            .cloned()
378            .unwrap_or_default()
379    };
380    let digest = |entry: &serde_json::Value, key: &str| -> Option<Sha256> {
381        entry.get(key)?.as_str()?.parse().ok()
382    };
383    let destination = |entry: &serde_json::Value| -> Option<String> {
384        Some(entry.get("destination")?.as_str()?.to_string())
385    };
386    Some(AnyRecord {
387        canon_version: held.get("canon_version")?.as_str()?.parse().ok()?,
388        profile: serde_json::from_value(held.get("profile")?.clone()).ok()?,
389        docs_root: serde_json::from_value(held.get("docs_root")?.clone()).ok()?,
390        managed_files: files("managed_files")
391            .iter()
392            .filter_map(|entry| Some((destination(entry)?, digest(entry, "sha256")?)))
393            .collect(),
394        // All or nothing. An entry this engine cannot read is a record it
395        // cannot vouch for, and dropping it would report a managed region
396        // as absent, which is what lets the next landing overwrite it.
397        integration_blocks: files("integration_blocks")
398            .iter()
399            .map(|entry| {
400                Some((
401                    entry.get("path")?.as_str()?.to_string(),
402                    digest(entry, "marker_hash")?,
403                ))
404            })
405            .collect::<Option<Vec<_>>>()?,
406        adopted_files: files("adopted_files")
407            .iter()
408            .filter_map(|entry| {
409                Some((
410                    destination(entry)?,
411                    digest(entry, "sha256")?,
412                    digest(entry, "baseline_sha256")?,
413                ))
414            })
415            .collect(),
416    })
417}
418
419/// Walk the corpus, reading only what a detector can prove.
420fn read_corpus(target: &Utf8Path) -> Result<Corpus, AppError> {
421    let mut corpus = Corpus::default();
422    for root in DOC_ROOTS {
423        let path = target.join(root);
424        if path.is_dir() && std::fs::read_dir(&path)?.next().is_some() {
425            corpus.populated_doc_roots.push((*root).to_string());
426        }
427        if path.join("specs").is_dir() {
428            corpus.has_specs_directory = true;
429        }
430    }
431
432    for entry in walkdir::WalkDir::new(target)
433        .into_iter()
434        .filter_entry(|entry| {
435            entry.depth() == 0
436                || !entry.file_type().is_dir()
437                || !SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref())
438        })
439    {
440        let entry = entry.map_err(|source| AppError::Io(std::io::Error::from(source)))?;
441        if !entry.file_type().is_file() {
442            continue;
443        }
444        let Ok(path) = Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) else {
445            continue;
446        };
447        let Ok(relative) = path.strip_prefix(target) else {
448            continue;
449        };
450        let Ok(held) = TargetPath::new(relative.as_str()) else {
451            continue;
452        };
453        let name = relative.file_name().unwrap_or_default();
454        let extension = relative.extension().unwrap_or_default();
455        if !DOC_EXTENSIONS.contains(&extension) {
456            continue;
457        }
458        // Root metadata is what every repository carries, whether or not
459        // it documents itself.
460        let stem = relative.file_stem().unwrap_or_default().to_lowercase();
461        if relative
462            .parent()
463            .is_none_or(|parent| parent.as_str().is_empty())
464            && ROOT_METADATA.contains(&stem.as_str())
465        {
466            continue;
467        }
468        corpus.documents.push(held.clone());
469        if is_spec_shaped(name) {
470            corpus.spec_shaped.push(held.clone());
471            let text = std::fs::read_to_string(&path).unwrap_or_default();
472            if crate::embedded::rule_ids_in(&text).next().is_none() {
473                corpus.spec_without_rule_id.push(held.clone());
474            }
475        }
476        if is_ordinal_name(name) {
477            corpus.ordinal_named.push(held.clone());
478        }
479        if is_record_shaped(name)
480            && relative
481                .parent()
482                .is_none_or(|parent| parent.file_name() != Some("decisions"))
483        {
484            corpus.records_outside_decisions.push(held);
485        }
486    }
487    corpus.documents.sort();
488    corpus.spec_shaped.sort();
489    corpus.spec_without_rule_id.sort();
490    corpus.ordinal_named.sort();
491    corpus.records_outside_decisions.sort();
492    Ok(corpus)
493}
494
495/// What the recorded destinations hold, keyed by path.
496#[must_use]
497pub fn held_by_path(installation: Option<&Installation>) -> BTreeMap<String, Sha256> {
498    let mut held = BTreeMap::new();
499    let Some(installation) = installation else {
500        return held;
501    };
502    for file in installation.managed.iter().chain(&installation.adopted) {
503        if let Some(digest) = file.held.clone() {
504            held.insert(file.path.as_str().to_string(), digest);
505        }
506    }
507    held
508}
509
510#[cfg(test)]
511mod tests {
512    #![allow(
513        clippy::unwrap_used,
514        reason = "a test panics as its failure signal, not as control flow"
515    )]
516
517    use super::*;
518
519    fn scratch() -> (tempfile::TempDir, Utf8PathBuf) {
520        let dir = tempfile::tempdir().unwrap();
521        let root = Utf8PathBuf::from(dir.path().to_str().unwrap());
522        std::fs::create_dir(root.join(".git")).unwrap();
523        (dir, root)
524    }
525
526    #[test]
527    fn an_empty_target_reads_as_empty_and_unsettled() {
528        let (_dir, root) = scratch();
529        let held = observe(&root).unwrap();
530        assert!(held.repository.empty);
531        assert!(held.repository.version_controlled);
532        assert!(held.installation.is_none());
533        assert_eq!(held.invalid, None);
534        assert!(!held.corpus.settled());
535    }
536
537    #[test]
538    fn root_metadata_is_not_a_corpus() {
539        let (_dir, root) = scratch();
540        std::fs::write(root.join("README.md"), "# x\n").unwrap();
541        std::fs::write(root.join("CHANGELOG.md"), "# x\n").unwrap();
542        let held = observe(&root).unwrap();
543        assert!(!held.repository.empty);
544        assert!(!held.corpus.settled(), "{:?}", held.corpus.documents);
545    }
546
547    #[test]
548    fn a_populated_documentation_root_is_a_settled_corpus() {
549        let (_dir, root) = scratch();
550        crate::adapters::fs::write_file(&root.join("docs/guide.md"), b"# guide\n").unwrap();
551        let held = observe(&root).unwrap();
552        assert_eq!(held.corpus.populated_doc_roots, ["docs"]);
553        assert!(held.corpus.settled());
554        assert_eq!(held.corpus.documents.len(), 1);
555    }
556
557    #[test]
558    fn a_documentation_root_of_empty_directories_is_not_a_corpus() {
559        let (_dir, root) = scratch();
560        std::fs::create_dir_all(root.join("_docs/specs")).unwrap();
561        let held = observe(&root).unwrap();
562        assert_eq!(held.corpus.populated_doc_roots, ["_docs"]);
563        assert!(!held.corpus.settled(), "a layout is not a corpus");
564    }
565
566    #[test]
567    fn the_corpus_reads_only_what_a_detector_can_prove() {
568        let (_dir, root) = scratch();
569        crate::adapters::fs::write_file(&root.join("docs/specs/SPEC-x.md"), b"# x\n").unwrap();
570        crate::adapters::fs::write_file(&root.join("docs/specs/SPEC-y.md"), b"### `a-b:c-d` - t\n")
571            .unwrap();
572        crate::adapters::fs::write_file(&root.join("docs/01-intro.md"), b"# x\n").unwrap();
573        crate::adapters::fs::write_file(&root.join("docs/ADR-a-choice.md"), b"# x\n").unwrap();
574        crate::adapters::fs::write_file(&root.join("docs/decisions/ADR-b-choice.md"), b"# x\n")
575            .unwrap();
576        crate::adapters::fs::write_file(&root.join("docs/specs/notes.md"), b"# x\n").unwrap();
577
578        let corpus = observe(&root).unwrap().corpus;
579        assert!(corpus.has_specs_directory);
580        assert_eq!(corpus.spec_shaped.len(), 2);
581        assert_eq!(
582            corpus
583                .spec_without_rule_id
584                .iter()
585                .map(TargetPath::as_str)
586                .collect::<Vec<_>>(),
587            ["docs/specs/SPEC-x.md"]
588        );
589        assert_eq!(
590            corpus
591                .ordinal_named
592                .iter()
593                .map(TargetPath::as_str)
594                .collect::<Vec<_>>(),
595            ["docs/01-intro.md"]
596        );
597        assert_eq!(
598            corpus
599                .records_outside_decisions
600                .iter()
601                .map(TargetPath::as_str)
602                .collect::<Vec<_>>(),
603            ["docs/ADR-a-choice.md"]
604        );
605    }
606
607    #[test]
608    fn a_record_that_does_not_parse_is_invalid_and_never_absent() {
609        let (_dir, root) = scratch();
610        crate::adapters::fs::write_file(&root.join(MANIFEST_PATH), b"{not json").unwrap();
611        let held = observe(&root).unwrap();
612        assert!(held.installation.is_none());
613        assert!(held.invalid.is_some(), "a broken record read as absent");
614    }
615
616    #[test]
617    fn an_unreadable_target_is_a_command_error_and_not_a_plan() {
618        let (_dir, root) = scratch();
619        std::fs::write(root.join("a-file"), b"x").unwrap();
620        let error = observe(&root.join("a-file")).unwrap_err();
621        assert_eq!(error.exit_code(), 64);
622        assert!(observe(&root.join("absent")).is_err());
623    }
624}