1use 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#[derive(Debug, Clone)]
34pub struct Workspace {
35 pub root: PathBuf,
36}
37
38impl Workspace {
39 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 pub fn aristo_dir(&self) -> PathBuf {
67 self.root.join(".aristo")
68 }
69
70 pub fn index_path(&self) -> PathBuf {
72 self.aristo_dir().join("index.toml")
73 }
74
75 pub fn canon_matches_path(&self) -> PathBuf {
81 self.aristo_dir().join("canon-matches.toml")
82 }
83
84 pub fn expectations_path(&self) -> PathBuf {
89 self.aristo_dir().join("expectations.toml")
90 }
91
92 pub fn specs_dir(&self) -> PathBuf {
94 self.aristo_dir().join("specs")
95 }
96
97 pub fn doc_dir(&self) -> PathBuf {
99 self.aristo_dir().join("doc")
100 }
101
102 pub fn sessions_dir(&self) -> PathBuf {
106 self.aristo_dir().join("sessions")
107 }
108
109 pub fn sessions_active_pointer(&self) -> PathBuf {
112 self.sessions_dir().join(".active")
113 }
114
115 pub fn sessions_active_session_dir(&self) -> PathBuf {
117 self.sessions_dir().join("active")
118 }
119
120 pub fn sessions_closed_dir(&self) -> PathBuf {
122 self.sessions_dir().join("closed")
123 }
124
125 pub fn sessions_rejections_log(&self) -> PathBuf {
127 self.sessions_dir().join("rejections.log")
128 }
129
130 pub fn sessions_backlog_dir(&self) -> PathBuf {
132 self.sessions_dir().join("backlog")
133 }
134
135 pub fn config_path(&self) -> PathBuf {
137 self.root.join("aristo.toml")
138 }
139
140 #[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) whenever the \
153 config has any parse error. Commands that need parse errors \
154 surfaced must read and \
155 parse directly.",
156 verify = "neural",
157 id = "workspace_load_config_degrades_to_default"
158 )]
159 pub fn load_config(&self) -> aristo_core::config::ConfigFile {
160 let Ok(text) = std::fs::read_to_string(self.config_path()) else {
161 return aristo_core::config::ConfigFile::default();
162 };
163 toml::from_str(&text).unwrap_or_default()
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::TempDir;
171
172 fn touch(p: &Path) {
173 std::fs::write(p, "").unwrap();
174 }
175
176 #[test]
177 fn finds_workspace_at_start_dir() {
178 let tmp = TempDir::new().unwrap();
179 touch(&tmp.path().join("aristo.toml"));
180 let ws = Workspace::find(Some(tmp.path())).unwrap();
181 assert_eq!(
186 ws.root.canonicalize().unwrap(),
187 tmp.path().canonicalize().unwrap()
188 );
189 }
190
191 #[test]
192 fn finds_workspace_in_ancestor() {
193 let tmp = TempDir::new().unwrap();
194 touch(&tmp.path().join("aristo.toml"));
195 let nested = tmp.path().join("a/b/c/d");
196 std::fs::create_dir_all(&nested).unwrap();
197 let ws = Workspace::find(Some(&nested)).unwrap();
198 assert_eq!(
199 ws.root.canonicalize().unwrap(),
200 tmp.path().canonicalize().unwrap()
201 );
202 }
203
204 #[test]
205 fn errors_when_no_aristo_toml_in_chain() {
206 let tmp = TempDir::new().unwrap();
207 let nested = tmp.path().join("nope");
212 std::fs::create_dir(&nested).unwrap();
213 match Workspace::find(Some(&nested)) {
218 Err(WorkspaceError::NotFound { .. }) => {}
219 Ok(ws) => assert_ne!(
220 ws.root, nested,
221 "walk should not stop in our empty temp dir"
222 ),
223 }
224 }
225
226 #[test]
227 fn aristo_dir_paths_compose_correctly() {
228 let ws = Workspace {
229 root: PathBuf::from("/proj"),
230 };
231 assert_eq!(ws.aristo_dir(), PathBuf::from("/proj/.aristo"));
232 assert_eq!(ws.index_path(), PathBuf::from("/proj/.aristo/index.toml"));
233 assert_eq!(
234 ws.expectations_path(),
235 PathBuf::from("/proj/.aristo/expectations.toml")
236 );
237 assert_eq!(ws.specs_dir(), PathBuf::from("/proj/.aristo/specs"));
238 assert_eq!(ws.doc_dir(), PathBuf::from("/proj/.aristo/doc"));
239 assert_eq!(ws.config_path(), PathBuf::from("/proj/aristo.toml"));
240 }
241
242 #[test]
243 fn session_paths_compose_under_sessions_dir() {
244 let ws = Workspace {
245 root: PathBuf::from("/proj"),
246 };
247 assert_eq!(ws.sessions_dir(), PathBuf::from("/proj/.aristo/sessions"));
248 assert_eq!(
249 ws.sessions_active_pointer(),
250 PathBuf::from("/proj/.aristo/sessions/.active")
251 );
252 assert_eq!(
253 ws.sessions_active_session_dir(),
254 PathBuf::from("/proj/.aristo/sessions/active")
255 );
256 assert_eq!(
257 ws.sessions_closed_dir(),
258 PathBuf::from("/proj/.aristo/sessions/closed")
259 );
260 assert_eq!(
261 ws.sessions_rejections_log(),
262 PathBuf::from("/proj/.aristo/sessions/rejections.log")
263 );
264 assert_eq!(
265 ws.sessions_backlog_dir(),
266 PathBuf::from("/proj/.aristo/sessions/backlog")
267 );
268 }
269}