1use 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
28const DOC_ROOTS: &[&str] = &["docs", "_docs", "doc", "documentation"];
30
31const ROOT_METADATA: &[&str] = &[
33 "readme",
34 "license",
35 "licence",
36 "contributing",
37 "changelog",
38 "agents",
39 "claude",
40 "code_of_conduct",
41];
42
43const SKIPPED: &[&str] = &[".git", ".jj", "target", "node_modules", INSTANCE_DIR];
45
46const DOC_EXTENSIONS: &[&str] = &["md", "markdown", "adoc", "rst", "org"];
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Repository {
52 pub root: Utf8PathBuf,
54 pub version_controlled: bool,
56 pub empty: bool,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct RecordedFile {
63 pub path: TargetPath,
65 pub recorded: Sha256,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub baseline: Option<Sha256>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub held: Option<Sha256>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Installation {
78 pub canon_version: CanonVersion,
80 pub profile: ProfileId,
82 pub docs_root: DocsRoot,
84 pub record_sha256: Sha256,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub declaration_sha256: Option<Sha256>,
89 pub managed: Vec<RecordedFile>,
91 pub adopted: Vec<RecordedFile>,
93 pub blocks: Vec<RecordedBlock>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RecordedBlock {
100 pub path: TargetPath,
102 pub recorded: Sha256,
104 pub held: Option<Sha256>,
107}
108
109impl Installation {
110 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Host {
126 pub offline: bool,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub cache_root: Option<Utf8PathBuf>,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Corpus {
136 pub populated_doc_roots: Vec<String>,
138 pub documents: Vec<TargetPath>,
140 pub spec_shaped: Vec<TargetPath>,
142 pub spec_without_rule_id: Vec<TargetPath>,
144 pub ordinal_named: Vec<TargetPath>,
146 pub records_outside_decisions: Vec<TargetPath>,
148 pub has_specs_directory: bool,
150}
151
152impl Corpus {
153 #[must_use]
160 pub const fn settled(&self) -> bool {
161 !self.documents.is_empty()
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Observation {
168 pub repository: Repository,
170 pub installation: Option<Installation>,
172 pub invalid: Option<String>,
174 pub host: Host,
176 pub corpus: Corpus,
178}
179
180pub 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
224fn 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
236fn 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 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 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 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
352fn 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
362struct 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 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
419fn 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 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#[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}