Skip to main content

aristo_cli/
workspace.rs

1//! Workspace discovery: find `aristo.toml` by walking up from a starting
2//! directory.
3//!
4//! Aristo's notion of "workspace" matches Cargo's: the directory containing
5//! `aristo.toml` is the root, and `.aristo/{index.toml, specs/, doc/}`
6//! lives next to it. Walking upward from the user's cwd lets `aristo`
7//! commands work from any subdirectory of a project, like `cargo` does.
8
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, PartialEq, Eq)]
12pub enum WorkspaceError {
13    NotFound { searched_from: PathBuf },
14}
15
16impl std::fmt::Display for WorkspaceError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            WorkspaceError::NotFound { searched_from } => {
20                write!(
21                    f,
22                    "no aristo.toml found at or above {}",
23                    searched_from.display()
24                )
25            }
26        }
27    }
28}
29
30impl std::error::Error for WorkspaceError {}
31
32/// A located Aristo workspace.
33#[derive(Debug, Clone)]
34pub struct Workspace {
35    pub root: PathBuf,
36}
37
38impl Workspace {
39    /// Find the workspace by walking upward from `start` (or cwd if None)
40    /// looking for an `aristo.toml`. The first ancestor directory that
41    /// contains one becomes the workspace root.
42    pub fn find(start: Option<&Path>) -> Result<Self, WorkspaceError> {
43        let start_buf = match start {
44            Some(p) => p.to_path_buf(),
45            None => std::env::current_dir().expect("current_dir readable for workspace discovery"),
46        };
47        let mut cur: &Path = &start_buf;
48        loop {
49            if cur.join("aristo.toml").is_file() {
50                return Ok(Workspace {
51                    root: cur.to_path_buf(),
52                });
53            }
54            cur = match cur.parent() {
55                Some(p) => p,
56                None => {
57                    return Err(WorkspaceError::NotFound {
58                        searched_from: start_buf,
59                    });
60                }
61            };
62        }
63    }
64
65    /// Path to the `.aristo/` state directory.
66    pub fn aristo_dir(&self) -> PathBuf {
67        self.root.join(".aristo")
68    }
69
70    /// Path to `.aristo/index.toml`.
71    pub fn index_path(&self) -> PathBuf {
72        self.aristo_dir().join("index.toml")
73    }
74
75    /// Path to `.aristo/canon-matches.toml` — the per-repo cache of
76    /// server-side canon match responses (committed by default).
77    /// See `aristo_core::canon::cache` for the schema and
78    /// `../aretta-sdk/docs/mockups/13-canon-and-matching/README.md`
79    /// §L5 for the design.
80    pub fn canon_matches_path(&self) -> PathBuf {
81        self.aristo_dir().join("canon-matches.toml")
82    }
83
84    /// Path to `.aristo/expectations.toml` — the user-side known-failure
85    /// waiver sidecar (Phase 16 (c); committed by default). See
86    /// `aristo_core::expectations` for the schema. Written by
87    /// `aristo verify --accept`; read at verify time as a join.
88    pub fn expectations_path(&self) -> PathBuf {
89        self.aristo_dir().join("expectations.toml")
90    }
91
92    /// Path to `.aristo/specs/`.
93    pub fn specs_dir(&self) -> PathBuf {
94        self.aristo_dir().join("specs")
95    }
96
97    /// Path to `.aristo/doc/`.
98    pub fn doc_dir(&self) -> PathBuf {
99        self.aristo_dir().join("doc")
100    }
101
102    /// Path to `.aristo/sessions/`. Holds the review-session substrate's
103    /// local-only state — see `docs/decisions/review-sessions.md` §D5.
104    /// Everything under this directory is gitignored.
105    pub fn sessions_dir(&self) -> PathBuf {
106        self.aristo_dir().join("sessions")
107    }
108
109    /// Pointer file holding the active session id (if any). Existence
110    /// implies an active session; missing means none.
111    pub fn sessions_active_pointer(&self) -> PathBuf {
112        self.sessions_dir().join(".active")
113    }
114
115    /// Per-session TOML state for in-flight sessions.
116    pub fn sessions_active_session_dir(&self) -> PathBuf {
117        self.sessions_dir().join("active")
118    }
119
120    /// Closed-session audit trail.
121    pub fn sessions_closed_dir(&self) -> PathBuf {
122        self.sessions_dir().join("closed")
123    }
124
125    /// Append-only JSONL log of rejected items across all sessions.
126    pub fn sessions_rejections_log(&self) -> PathBuf {
127        self.sessions_dir().join("rejections.log")
128    }
129
130    /// Per-kind backlog directory (`backlog/<kind>.toml`).
131    pub fn sessions_backlog_dir(&self) -> PathBuf {
132        self.sessions_dir().join("backlog")
133    }
134
135    /// Path to `aristo.toml`.
136    pub fn config_path(&self) -> PathBuf {
137        self.root.join("aristo.toml")
138    }
139
140    /// Read + parse `aristo.toml`. Returns `ConfigFile::default()` on any
141    /// failure (missing file, parse error) — the per-command config-driven
142    /// behaviors all degrade gracefully when their relevant section is
143    /// absent, so a malformed config shouldn't break read commands.
144    /// Callers that need to surface parse errors (`aristo lint`'s
145    /// `aristo.toml` validation in a future slice) should read + parse
146    /// directly.
147    #[aristo::intent(
148        "Malformed or missing aristo.toml degrades to \
149         ConfigFile::default() rather than erroring. Reader commands \
150         stay functional with project defaults when the user's config \
151         has a typo. A refactor that propagates errors here would \
152         break every reader (show / list / status / lint) at first \
153         typo. Commands that need parse errors surfaced must read and \
154         parse directly.",
155        verify = "neural",
156        id = "workspace_load_config_degrades_to_default"
157    )]
158    pub fn load_config(&self) -> aristo_core::config::ConfigFile {
159        let Ok(text) = std::fs::read_to_string(self.config_path()) else {
160            return aristo_core::config::ConfigFile::default();
161        };
162        toml::from_str(&text).unwrap_or_default()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use tempfile::TempDir;
170
171    fn touch(p: &Path) {
172        std::fs::write(p, "").unwrap();
173    }
174
175    #[test]
176    fn finds_workspace_at_start_dir() {
177        let tmp = TempDir::new().unwrap();
178        touch(&tmp.path().join("aristo.toml"));
179        let ws = Workspace::find(Some(tmp.path())).unwrap();
180        // Compare on canonicalized form because macOS /tmp ↔ /private/tmp
181        // diverges between the temp-dir handle and the walked-up parent
182        // path. Workspace::find preserves the input verbatim, so we
183        // canonicalize both sides for a reliable equality check.
184        assert_eq!(
185            ws.root.canonicalize().unwrap(),
186            tmp.path().canonicalize().unwrap()
187        );
188    }
189
190    #[test]
191    fn finds_workspace_in_ancestor() {
192        let tmp = TempDir::new().unwrap();
193        touch(&tmp.path().join("aristo.toml"));
194        let nested = tmp.path().join("a/b/c/d");
195        std::fs::create_dir_all(&nested).unwrap();
196        let ws = Workspace::find(Some(&nested)).unwrap();
197        assert_eq!(
198            ws.root.canonicalize().unwrap(),
199            tmp.path().canonicalize().unwrap()
200        );
201    }
202
203    #[test]
204    fn errors_when_no_aristo_toml_in_chain() {
205        let tmp = TempDir::new().unwrap();
206        // Deliberately do NOT create aristo.toml. Walking up from a temp dir
207        // shouldn't hit any aristo.toml on a clean system. (If a developer
208        // has an aristo.toml at /tmp or /, this test would be misleading;
209        // but that's pathological enough to ignore.)
210        let nested = tmp.path().join("nope");
211        std::fs::create_dir(&nested).unwrap();
212        // We can't assert success-or-fail in general because of the above
213        // ambiguity, so just ensure: if we DO find one, it's not the temp
214        // dir we just made. (Validates that the walk reached *some* root
215        // distinct from our empty start.)
216        match Workspace::find(Some(&nested)) {
217            Err(WorkspaceError::NotFound { .. }) => {}
218            Ok(ws) => assert_ne!(
219                ws.root, nested,
220                "walk should not stop in our empty temp dir"
221            ),
222        }
223    }
224
225    #[test]
226    fn aristo_dir_paths_compose_correctly() {
227        let ws = Workspace {
228            root: PathBuf::from("/proj"),
229        };
230        assert_eq!(ws.aristo_dir(), PathBuf::from("/proj/.aristo"));
231        assert_eq!(ws.index_path(), PathBuf::from("/proj/.aristo/index.toml"));
232        assert_eq!(
233            ws.expectations_path(),
234            PathBuf::from("/proj/.aristo/expectations.toml")
235        );
236        assert_eq!(ws.specs_dir(), PathBuf::from("/proj/.aristo/specs"));
237        assert_eq!(ws.doc_dir(), PathBuf::from("/proj/.aristo/doc"));
238        assert_eq!(ws.config_path(), PathBuf::from("/proj/aristo.toml"));
239    }
240
241    #[test]
242    fn session_paths_compose_under_sessions_dir() {
243        let ws = Workspace {
244            root: PathBuf::from("/proj"),
245        };
246        assert_eq!(ws.sessions_dir(), PathBuf::from("/proj/.aristo/sessions"));
247        assert_eq!(
248            ws.sessions_active_pointer(),
249            PathBuf::from("/proj/.aristo/sessions/.active")
250        );
251        assert_eq!(
252            ws.sessions_active_session_dir(),
253            PathBuf::from("/proj/.aristo/sessions/active")
254        );
255        assert_eq!(
256            ws.sessions_closed_dir(),
257            PathBuf::from("/proj/.aristo/sessions/closed")
258        );
259        assert_eq!(
260            ws.sessions_rejections_log(),
261            PathBuf::from("/proj/.aristo/sessions/rejections.log")
262        );
263        assert_eq!(
264            ws.sessions_backlog_dir(),
265            PathBuf::from("/proj/.aristo/sessions/backlog")
266        );
267    }
268}