1use std::collections::BTreeMap;
16
17use camino::{Utf8Path, Utf8PathBuf};
18use serde::Serialize;
19
20use crate::domain::manifest::PlanZone;
21use crate::domain::profile::{DocsRoot, ProfileId};
22
23pub const HOME_VAR: &str = "HOME";
25pub const CLAUDE_CONFIG_DIR_VAR: &str = "CLAUDE_CONFIG_DIR";
27pub const XDG_STATE_HOME_VAR: &str = "XDG_STATE_HOME";
29pub const XDG_CACHE_HOME_VAR: &str = "XDG_CACHE_HOME";
31pub const OFFLINE_VAR: &str = "SDD_OFFLINE";
33pub const PLAN_ZONE_VAR: &str = "SDD_PLAN_ZONE";
35pub const DOCS_SCRATCH_VAR: &str = "SDD_DOCS_SCRATCH";
37
38pub const TOOL_DIR: &str = "spec-driven-docs";
40
41pub const INSTANCE_DIR: &str = ".spec-driven-docs";
43pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
45pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
47pub const DEBT_PATH: &str = ".spec-driven-docs/debt.yaml";
49pub const LEGACY_DEBT_PATH: &str = ".spec-driven-docs/chapter-size-debt.txt";
51pub const HOOKS_CONFIG_PATH: &str = ".pre-commit-config.yaml";
53pub const AGENTS_DIGEST_PATH: &str = "AGENTS.md";
55
56pub const PRUNABLE_ROOTS: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
58
59pub const STATE_ROOT: &str = ".local/state/spec-driven-docs";
61pub const CACHE_ROOT: &str = ".cache/spec-driven-docs";
63pub const SKILL_RECEIPT_FILE: &str = "skills.json";
65pub const SKILL_FILE: &str = "SKILL.md";
67pub const SKILL_REFERENCES_DIR: &str = "references";
69
70pub const SKILL_LOCK_FILE: &str = "skills.lock";
72pub const SKILL_JOURNAL_FILE: &str = "skills.journal";
74pub const BACKUPS_DIR: &str = "backups";
76pub const PLAN_STORE_DIR: &str = "plans";
78pub const BUNDLE_CACHE_DIR: &str = "bundles";
80
81pub const LEGACY_SKILL_RECEIPT_PATH: &str = ".local/state/spec-driven-docs/skills.json";
87pub const LEGACY_SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
93pub const CLAUDE_ROOT: &str = ".claude/skills";
95pub const AGENTS_ROOT: &str = ".agents/skills";
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
100#[serde(rename_all = "kebab-case")]
101pub enum AgentId {
102 Claude,
104 Agents,
106}
107
108impl AgentId {
109 #[must_use]
111 pub const fn as_str(self) -> &'static str {
112 match self {
113 Self::Claude => "claude",
114 Self::Agents => "agents",
115 }
116 }
117}
118
119impl std::fmt::Display for AgentId {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.write_str(self.as_str())
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct AgentRoot {
128 pub id: AgentId,
130 pub default: &'static str,
132 pub config_env: Option<&'static str>,
134 pub relocated: &'static str,
136}
137
138const CLAUDE: AgentRoot = AgentRoot {
140 id: AgentId::Claude,
141 default: CLAUDE_ROOT,
142 config_env: Some(CLAUDE_CONFIG_DIR_VAR),
143 relocated: "skills",
144};
145
146const AGENTS: AgentRoot = AgentRoot {
148 id: AgentId::Agents,
149 default: AGENTS_ROOT,
150 config_env: None,
151 relocated: "skills",
152};
153
154pub const AGENT_ROOTS: &[AgentRoot] = &[CLAUDE, AGENTS];
161
162#[must_use]
164pub const fn agent_root(id: AgentId) -> &'static AgentRoot {
165 match id {
166 AgentId::Claude => &CLAUDE,
167 AgentId::Agents => &AGENTS,
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum PathSource {
175 Recorded,
177 Default,
179 Env,
181 Profile,
183 Proposal,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
189pub struct PathEntry {
190 pub path: Utf8PathBuf,
192 pub source: PathSource,
194}
195
196impl PathEntry {
197 #[must_use]
199 pub fn default_at(path: impl Into<Utf8PathBuf>) -> Self {
200 Self {
201 path: path.into(),
202 source: PathSource::Default,
203 }
204 }
205
206 #[must_use]
208 pub fn from_env(path: impl Into<Utf8PathBuf>) -> Self {
209 Self {
210 path: path.into(),
211 source: PathSource::Env,
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218pub struct AgentRootEntry {
219 pub id: AgentId,
221 pub path: Utf8PathBuf,
223 pub source: PathSource,
225 pub variable: Option<&'static str>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
236#[serde(tag = "kind", rename_all = "kebab-case")]
237pub enum ProjectLocation {
238 Tracked {
240 path: Utf8PathBuf,
242 source: PathSource,
244 },
245 Untracked {
247 path: Utf8PathBuf,
249 source: PathSource,
251 },
252 External {
254 path: Utf8PathBuf,
256 source: PathSource,
258 },
259 Env {
261 variable: &'static str,
263 value: Option<String>,
265 },
266 None,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
276#[serde(tag = "kind", rename_all = "kebab-case")]
277pub enum LocationChoice {
278 Observed {
280 path: Utf8PathBuf,
282 },
283 Env {
285 variable: &'static str,
287 value: Option<String>,
289 },
290 Operator,
292 None,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298pub struct Proposals {
299 pub plan_zone: Vec<LocationChoice>,
301 pub docs_scratch: Vec<LocationChoice>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
307pub struct UserPaths {
308 pub state_root: PathEntry,
310 pub cache_root: PathEntry,
312 pub skill_receipt: PathEntry,
314 pub plan_store: PathEntry,
316 pub bundle_cache: PathEntry,
318 pub agent_roots: Vec<AgentRootEntry>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324pub struct InstancePaths {
325 pub instance_dir: PathEntry,
327 pub manifest: PathEntry,
329 pub declaration: PathEntry,
331 pub debt: PathEntry,
333 pub legacy_debt: PathEntry,
335 pub hooks_config: PathEntry,
337 pub agents_digest: PathEntry,
339 pub docs_root: PathEntry,
341 pub specs: PathEntry,
343 pub decisions: PathEntry,
345 pub reference: PathEntry,
347 pub guides: PathEntry,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
353pub struct ActivePaths {
354 pub profile: ProfileId,
356 pub destinations: InstancePaths,
358 pub plan_zone: ProjectLocation,
360 pub docs_scratch: ProjectLocation,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct CandidatePaths {
367 pub profile: ProfileId,
369 pub destinations: InstancePaths,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375pub struct Paths {
376 pub user: Option<UserPaths>,
379 pub active: Option<ActivePaths>,
381 pub candidates: BTreeMap<String, CandidatePaths>,
383 pub proposals: Proposals,
385}
386
387#[derive(Debug, Clone, Default, PartialEq, Eq)]
389pub struct UserEnv {
390 pub home: Option<Utf8PathBuf>,
392 pub claude_config_dir: Option<Utf8PathBuf>,
394 pub xdg_state_home: Option<Utf8PathBuf>,
396 pub xdg_cache_home: Option<Utf8PathBuf>,
398 pub plan_zone: Option<String>,
400 pub docs_scratch: Option<String>,
402}
403
404#[must_use]
410pub fn variable(name: &str) -> Option<String> {
411 std::env::var(name)
412 .ok()
413 .map(|value| value.trim().to_string())
414 .filter(|value| !value.is_empty())
415}
416
417impl UserEnv {
418 #[must_use]
420 pub fn from_process() -> Self {
421 let path = |name: &str| variable(name).map(Utf8PathBuf::from);
422 Self {
423 home: path(HOME_VAR),
424 claude_config_dir: path(CLAUDE_CONFIG_DIR_VAR),
425 xdg_state_home: path(XDG_STATE_HOME_VAR),
426 xdg_cache_home: path(XDG_CACHE_HOME_VAR),
427 plan_zone: variable(PLAN_ZONE_VAR),
428 docs_scratch: variable(DOCS_SCRATCH_VAR),
429 }
430 }
431
432 #[must_use]
434 pub fn state_root(&self) -> Option<PathEntry> {
435 if let Some(base) = self.xdg_state_home.as_ref() {
436 return Some(PathEntry::from_env(base.join(TOOL_DIR)));
437 }
438 self.home
439 .as_ref()
440 .map(|home| PathEntry::default_at(home.join(STATE_ROOT)))
441 }
442
443 #[must_use]
449 pub fn legacy_state_root(&self) -> Option<Utf8PathBuf> {
450 self.home.as_ref().map(|home| home.join(STATE_ROOT))
451 }
452
453 #[must_use]
455 pub fn cache_root(&self) -> Option<PathEntry> {
456 if let Some(base) = self.xdg_cache_home.as_ref() {
457 return Some(PathEntry::from_env(base.join(TOOL_DIR)));
458 }
459 self.home
460 .as_ref()
461 .map(|home| PathEntry::default_at(home.join(CACHE_ROOT)))
462 }
463
464 #[must_use]
466 pub fn agent_root(&self, id: AgentId) -> Option<AgentRootEntry> {
467 let row = agent_root(id);
468 let home = self.home.as_ref()?;
469 let relocated = match row.id {
470 AgentId::Claude => self.claude_config_dir.as_ref(),
471 AgentId::Agents => None,
472 };
473 Some(relocated.map_or_else(
474 || AgentRootEntry {
475 id: row.id,
476 path: home.join(row.default),
477 source: PathSource::Default,
478 variable: None,
479 },
480 |base| AgentRootEntry {
481 id: row.id,
482 path: base.join(row.relocated),
483 source: PathSource::Env,
484 variable: row.config_env,
485 },
486 ))
487 }
488
489 #[must_use]
495 pub fn agent_roots(&self, selected: &[AgentId]) -> Vec<AgentRootEntry> {
496 let mut resolved: Vec<AgentRootEntry> = Vec::new();
497 for row in AGENT_ROOTS {
498 if !selected.contains(&row.id) {
499 continue;
500 }
501 let Some(entry) = self.agent_root(row.id) else {
502 continue;
503 };
504 if resolved.iter().any(|held| held.path == entry.path) {
505 continue;
506 }
507 resolved.push(entry);
508 }
509 resolved
510 }
511
512 #[must_use]
514 pub fn user_paths(&self) -> Option<UserPaths> {
515 let state = self.state_root()?;
516 let cache = self.cache_root()?;
517 Some(UserPaths {
518 skill_receipt: PathEntry {
519 path: state.path.join(SKILL_RECEIPT_FILE),
520 source: state.source,
521 },
522 plan_store: PathEntry {
523 path: state.path.join(PLAN_STORE_DIR),
524 source: state.source,
525 },
526 bundle_cache: PathEntry {
527 path: cache.path.join(BUNDLE_CACHE_DIR),
528 source: cache.source,
529 },
530 agent_roots: self.agent_roots(&[AgentId::Claude, AgentId::Agents]),
531 state_root: state,
532 cache_root: cache,
533 })
534 }
535}
536
537#[must_use]
539pub fn instance_paths(docs_root: DocsRoot) -> InstancePaths {
540 let docs = Utf8PathBuf::from(docs_root.as_str());
541 let under = |leaf: &str| PathEntry {
542 path: docs.join(leaf),
543 source: PathSource::Profile,
544 };
545 InstancePaths {
546 instance_dir: PathEntry::default_at(INSTANCE_DIR),
547 manifest: PathEntry::default_at(MANIFEST_PATH),
548 declaration: PathEntry::default_at(CONFIG_PATH),
549 debt: PathEntry::default_at(DEBT_PATH),
550 legacy_debt: PathEntry::default_at(LEGACY_DEBT_PATH),
551 hooks_config: PathEntry::default_at(HOOKS_CONFIG_PATH),
552 agents_digest: PathEntry::default_at(AGENTS_DIGEST_PATH),
553 docs_root: PathEntry {
554 path: docs.clone(),
555 source: PathSource::Profile,
556 },
557 specs: under("specs"),
558 decisions: under("decisions"),
559 reference: under("reference"),
560 guides: under("guides"),
561 }
562}
563
564#[must_use]
566pub fn recorded_paths(docs_root: DocsRoot) -> InstancePaths {
567 let mut paths = instance_paths(docs_root);
568 for entry in [
569 &mut paths.docs_root,
570 &mut paths.specs,
571 &mut paths.decisions,
572 &mut paths.reference,
573 &mut paths.guides,
574 ] {
575 entry.source = PathSource::Recorded;
576 }
577 paths
578}
579
580#[must_use]
582pub fn candidates() -> BTreeMap<String, CandidatePaths> {
583 ProfileId::every()
584 .map(|profile| {
585 (
586 profile.as_str().to_string(),
587 CandidatePaths {
588 profile,
589 destinations: instance_paths(profile.profile().docs_root),
590 },
591 )
592 })
593 .collect()
594}
595
596#[must_use]
598pub fn plan_zone_location(zone: &PlanZone, env: &UserEnv) -> ProjectLocation {
599 match zone {
600 PlanZone::Tracked { path } => ProjectLocation::Tracked {
601 path: path.clone(),
602 source: PathSource::Recorded,
603 },
604 PlanZone::Untracked { path } => ProjectLocation::Untracked {
605 path: path.clone(),
606 source: PathSource::Recorded,
607 },
608 PlanZone::Env => ProjectLocation::Env {
609 variable: PLAN_ZONE_VAR,
610 value: env.plan_zone.clone(),
611 },
612 PlanZone::None => ProjectLocation::None,
613 }
614}
615
616#[must_use]
623pub fn docs_scratch_location(recorded: Option<&Utf8Path>, env: &UserEnv) -> ProjectLocation {
624 if env.docs_scratch.is_some() {
625 return ProjectLocation::Env {
626 variable: DOCS_SCRATCH_VAR,
627 value: env.docs_scratch.clone(),
628 };
629 }
630 let Some(path) = recorded else {
631 return ProjectLocation::None;
632 };
633 if path.is_absolute() || path.starts_with("..") {
634 return ProjectLocation::External {
635 path: path.to_owned(),
636 source: PathSource::Recorded,
637 };
638 }
639 ProjectLocation::Untracked {
640 path: path.to_owned(),
641 source: PathSource::Recorded,
642 }
643}
644
645pub const PLAN_ZONE_LEAVES: &[&str] = &["plan", "plans"];
651
652pub const DOCS_SCRATCH_LEAVES: &[&str] = &[".docs-scratch", ".scratch"];
654
655pub fn proposals(env: &UserEnv, held: impl Fn(&Utf8Path) -> bool) -> Proposals {
661 let mut plan_zone: Vec<LocationChoice> = Vec::new();
662 for profile in ProfileId::every() {
663 let docs = Utf8PathBuf::from(profile.profile().docs_root.as_str());
664 for leaf in PLAN_ZONE_LEAVES {
665 let candidate = docs.join(leaf);
666 if held(&candidate) && !plan_zone.iter().any(|choice| names(choice, &candidate)) {
667 plan_zone.push(LocationChoice::Observed { path: candidate });
668 }
669 }
670 }
671 plan_zone.push(LocationChoice::Env {
672 variable: PLAN_ZONE_VAR,
673 value: env.plan_zone.clone(),
674 });
675 plan_zone.push(LocationChoice::Operator);
676 plan_zone.push(LocationChoice::None);
677
678 let mut docs_scratch: Vec<LocationChoice> = Vec::new();
679 for leaf in DOCS_SCRATCH_LEAVES {
680 let candidate = Utf8PathBuf::from(*leaf);
681 if held(&candidate) {
682 docs_scratch.push(LocationChoice::Observed { path: candidate });
683 }
684 }
685 docs_scratch.push(LocationChoice::Env {
686 variable: DOCS_SCRATCH_VAR,
687 value: env.docs_scratch.clone(),
688 });
689 docs_scratch.push(LocationChoice::Operator);
690 docs_scratch.push(LocationChoice::None);
691
692 Proposals {
693 plan_zone,
694 docs_scratch,
695 }
696}
697
698fn names(choice: &LocationChoice, path: &Utf8Path) -> bool {
699 matches!(choice, LocationChoice::Observed { path: held } if held == path)
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn env(home: &str) -> UserEnv {
707 UserEnv {
708 home: Some(Utf8PathBuf::from(home)),
709 ..UserEnv::default()
710 }
711 }
712
713 #[test]
714 fn the_agent_root_table_has_two_rows_and_resolves_each_with_its_source() {
715 assert_eq!(AGENT_ROOTS.len(), 2);
716 let resolved = env("/h").agent_roots(&[AgentId::Claude, AgentId::Agents]);
717 assert_eq!(resolved.len(), 2);
718 assert_eq!(resolved[0].path, "/h/.claude/skills");
719 assert_eq!(resolved[0].source, PathSource::Default);
720 assert_eq!(resolved[0].variable, None);
721 assert_eq!(resolved[1].path, "/h/.agents/skills");
722 assert_eq!(resolved[1].id, AgentId::Agents);
723 }
724
725 #[test]
726 fn claude_config_dir_relocates_the_claude_root_and_nothing_else() {
727 let moved = UserEnv {
728 claude_config_dir: Some(Utf8PathBuf::from("/elsewhere/claude")),
729 ..env("/h")
730 };
731 let resolved = moved.agent_roots(&[AgentId::Claude, AgentId::Agents]);
732 assert_eq!(resolved[0].path, "/elsewhere/claude/skills");
733 assert_eq!(resolved[0].source, PathSource::Env);
734 assert_eq!(resolved[0].variable, Some(CLAUDE_CONFIG_DIR_VAR));
735 assert_eq!(resolved[1].path, "/h/.agents/skills");
736 assert_eq!(resolved[1].source, PathSource::Default);
737 }
738
739 #[test]
740 fn an_empty_claude_config_dir_is_treated_as_unset() {
741 let blank = UserEnv {
743 claude_config_dir: None,
744 ..env("/h")
745 };
746 assert_eq!(
747 blank.agent_root(AgentId::Claude).map(|root| root.path),
748 Some(Utf8PathBuf::from("/h/.claude/skills"))
749 );
750 }
751
752 #[test]
753 fn two_selected_roots_that_resolve_to_one_path_are_returned_once() {
754 let collided = UserEnv {
755 claude_config_dir: Some(Utf8PathBuf::from("/h/.agents")),
756 ..env("/h")
757 };
758 let resolved = collided.agent_roots(&[AgentId::Claude, AgentId::Agents]);
759 assert_eq!(resolved.len(), 1);
760 assert_eq!(resolved[0].path, "/h/.agents/skills");
761 assert_eq!(resolved[0].id, AgentId::Claude);
762 }
763
764 #[test]
765 fn the_cache_root_follows_xdg_cache_home_and_its_default() {
766 assert_eq!(
767 env("/h").cache_root(),
768 Some(PathEntry::default_at("/h/.cache/spec-driven-docs"))
769 );
770 let moved = UserEnv {
771 xdg_cache_home: Some(Utf8PathBuf::from("/c")),
772 ..env("/h")
773 };
774 assert_eq!(
775 moved.cache_root(),
776 Some(PathEntry::from_env("/c/spec-driven-docs"))
777 );
778 }
779
780 #[test]
781 fn the_state_root_follows_xdg_state_home_and_its_default() {
782 assert_eq!(
783 env("/h").state_root(),
784 Some(PathEntry::default_at("/h/.local/state/spec-driven-docs"))
785 );
786 let moved = UserEnv {
787 xdg_state_home: Some(Utf8PathBuf::from("/s")),
788 ..env("/h")
789 };
790 assert_eq!(
791 moved.state_root(),
792 Some(PathEntry::from_env("/s/spec-driven-docs"))
793 );
794 assert_eq!(
795 moved.legacy_state_root(),
796 Some(Utf8PathBuf::from("/h/.local/state/spec-driven-docs"))
797 );
798 }
799
800 #[test]
801 fn the_user_paths_hang_off_the_two_roots() {
802 let paths = env("/h").user_paths().unwrap();
803 assert_eq!(
804 paths.skill_receipt.path,
805 "/h/.local/state/spec-driven-docs/skills.json"
806 );
807 assert_eq!(
808 paths.plan_store.path,
809 "/h/.local/state/spec-driven-docs/plans"
810 );
811 assert_eq!(
812 paths.bundle_cache.path,
813 "/h/.cache/spec-driven-docs/bundles"
814 );
815 }
816
817 #[test]
818 fn the_home_relative_constants_agree_with_the_resolved_roots() {
819 let paths = env("/h").user_paths().unwrap();
820 assert_eq!(
821 paths.skill_receipt.path,
822 Utf8Path::new("/h").join(LEGACY_SKILL_RECEIPT_PATH)
823 );
824 }
825
826 #[test]
827 fn a_candidate_set_exists_for_every_profile() {
828 let candidates = candidates();
829 assert_eq!(candidates.len(), 2);
830 assert_eq!(candidates["codebase"].destinations.specs.path, "docs/specs");
831 assert_eq!(
832 candidates["knowledge-base"].destinations.specs.path,
833 "_docs/specs"
834 );
835 assert_eq!(
836 candidates["codebase"].destinations.declaration.source,
837 PathSource::Default
838 );
839 assert_eq!(
840 candidates["codebase"].destinations.specs.source,
841 PathSource::Profile
842 );
843 }
844
845 #[test]
846 fn a_recorded_location_reports_as_recorded_and_an_override_as_env() {
847 let plain = env("/h");
848 assert_eq!(
849 plan_zone_location(
850 &PlanZone::Tracked {
851 path: Utf8PathBuf::from("docs/plan")
852 },
853 &plain
854 ),
855 ProjectLocation::Tracked {
856 path: Utf8PathBuf::from("docs/plan"),
857 source: PathSource::Recorded,
858 }
859 );
860 let overridden = UserEnv {
861 plan_zone: Some("elsewhere".to_string()),
862 ..env("/h")
863 };
864 assert_eq!(
865 plan_zone_location(&PlanZone::Env, &overridden),
866 ProjectLocation::Env {
867 variable: PLAN_ZONE_VAR,
868 value: Some("elsewhere".to_string()),
869 }
870 );
871 }
872
873 #[test]
874 fn a_scratch_beside_the_checkout_reports_as_external() {
875 let plain = env("/h");
876 assert_eq!(
877 docs_scratch_location(Some(Utf8Path::new("../x.docs-scratch")), &plain),
878 ProjectLocation::External {
879 path: Utf8PathBuf::from("../x.docs-scratch"),
880 source: PathSource::Recorded,
881 }
882 );
883 assert_eq!(
884 docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &plain),
885 ProjectLocation::Untracked {
886 path: Utf8PathBuf::from(".docs-scratch"),
887 source: PathSource::Recorded,
888 }
889 );
890 assert_eq!(docs_scratch_location(None, &plain), ProjectLocation::None);
891 }
892
893 #[test]
894 fn a_proposal_carries_a_path_only_where_the_target_holds_one() {
895 let plain = env("/h");
896 let bare = proposals(&plain, |_| false);
897 assert!(
898 !bare
899 .plan_zone
900 .iter()
901 .any(|choice| matches!(choice, LocationChoice::Observed { .. }))
902 );
903 assert_eq!(bare.plan_zone.last(), Some(&LocationChoice::None));
904
905 let observed = proposals(&plain, |path| path == Utf8Path::new("docs/plan"));
906 assert_eq!(
907 observed.plan_zone.first(),
908 Some(&LocationChoice::Observed {
909 path: Utf8PathBuf::from("docs/plan")
910 })
911 );
912 }
913}