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 specs_dir(&self) -> PathBuf {
86 self.aristo_dir().join("specs")
87 }
88
89 pub fn doc_dir(&self) -> PathBuf {
91 self.aristo_dir().join("doc")
92 }
93
94 pub fn sessions_dir(&self) -> PathBuf {
98 self.aristo_dir().join("sessions")
99 }
100
101 pub fn sessions_active_pointer(&self) -> PathBuf {
104 self.sessions_dir().join(".active")
105 }
106
107 pub fn sessions_active_session_dir(&self) -> PathBuf {
109 self.sessions_dir().join("active")
110 }
111
112 pub fn sessions_closed_dir(&self) -> PathBuf {
114 self.sessions_dir().join("closed")
115 }
116
117 pub fn sessions_rejections_log(&self) -> PathBuf {
119 self.sessions_dir().join("rejections.log")
120 }
121
122 pub fn sessions_backlog_dir(&self) -> PathBuf {
124 self.sessions_dir().join("backlog")
125 }
126
127 pub fn config_path(&self) -> PathBuf {
129 self.root.join("aristo.toml")
130 }
131
132 #[aristo::intent(
140 "Malformed or missing aristo.toml degrades to \
141 ConfigFile::default() rather than erroring. Reader commands \
142 stay functional with project defaults when the user's config \
143 has a typo. A refactor that propagates errors here would \
144 break every reader (show / list / status / lint) at first \
145 typo. Commands that need parse errors surfaced must read and \
146 parse directly.",
147 verify = "neural",
148 id = "workspace_load_config_degrades_to_default"
149 )]
150 pub fn load_config(&self) -> aristo_core::config::ConfigFile {
151 let Ok(text) = std::fs::read_to_string(self.config_path()) else {
152 return aristo_core::config::ConfigFile::default();
153 };
154 toml::from_str(&text).unwrap_or_default()
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use tempfile::TempDir;
162
163 fn touch(p: &Path) {
164 std::fs::write(p, "").unwrap();
165 }
166
167 #[test]
168 fn finds_workspace_at_start_dir() {
169 let tmp = TempDir::new().unwrap();
170 touch(&tmp.path().join("aristo.toml"));
171 let ws = Workspace::find(Some(tmp.path())).unwrap();
172 assert_eq!(
177 ws.root.canonicalize().unwrap(),
178 tmp.path().canonicalize().unwrap()
179 );
180 }
181
182 #[test]
183 fn finds_workspace_in_ancestor() {
184 let tmp = TempDir::new().unwrap();
185 touch(&tmp.path().join("aristo.toml"));
186 let nested = tmp.path().join("a/b/c/d");
187 std::fs::create_dir_all(&nested).unwrap();
188 let ws = Workspace::find(Some(&nested)).unwrap();
189 assert_eq!(
190 ws.root.canonicalize().unwrap(),
191 tmp.path().canonicalize().unwrap()
192 );
193 }
194
195 #[test]
196 fn errors_when_no_aristo_toml_in_chain() {
197 let tmp = TempDir::new().unwrap();
198 let nested = tmp.path().join("nope");
203 std::fs::create_dir(&nested).unwrap();
204 match Workspace::find(Some(&nested)) {
209 Err(WorkspaceError::NotFound { .. }) => {}
210 Ok(ws) => assert_ne!(
211 ws.root, nested,
212 "walk should not stop in our empty temp dir"
213 ),
214 }
215 }
216
217 #[test]
218 fn aristo_dir_paths_compose_correctly() {
219 let ws = Workspace {
220 root: PathBuf::from("/proj"),
221 };
222 assert_eq!(ws.aristo_dir(), PathBuf::from("/proj/.aristo"));
223 assert_eq!(ws.index_path(), PathBuf::from("/proj/.aristo/index.toml"));
224 assert_eq!(ws.specs_dir(), PathBuf::from("/proj/.aristo/specs"));
225 assert_eq!(ws.doc_dir(), PathBuf::from("/proj/.aristo/doc"));
226 assert_eq!(ws.config_path(), PathBuf::from("/proj/aristo.toml"));
227 }
228
229 #[test]
230 fn session_paths_compose_under_sessions_dir() {
231 let ws = Workspace {
232 root: PathBuf::from("/proj"),
233 };
234 assert_eq!(ws.sessions_dir(), PathBuf::from("/proj/.aristo/sessions"));
235 assert_eq!(
236 ws.sessions_active_pointer(),
237 PathBuf::from("/proj/.aristo/sessions/.active")
238 );
239 assert_eq!(
240 ws.sessions_active_session_dir(),
241 PathBuf::from("/proj/.aristo/sessions/active")
242 );
243 assert_eq!(
244 ws.sessions_closed_dir(),
245 PathBuf::from("/proj/.aristo/sessions/closed")
246 );
247 assert_eq!(
248 ws.sessions_rejections_log(),
249 PathBuf::from("/proj/.aristo/sessions/rejections.log")
250 );
251 assert_eq!(
252 ws.sessions_backlog_dir(),
253 PathBuf::from("/proj/.aristo/sessions/backlog")
254 );
255 }
256}