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::plan::finding::{is_ordinal_name, is_record_shaped, is_spec_shaped};
26use crate::plan::operation::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.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
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 Ok(text) = std::fs::read_to_string(&path) else {
244 return (None, None);
245 };
246 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 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
342fn 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
352struct 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 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
409fn 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 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#[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}