1use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9pub const MAX_RECENT: usize = 20;
11pub const MAX_FOLDERS: usize = 200;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Recent {
17 pub path: String,
19 pub name: String,
21 pub opened_at: u64,
23 #[serde(default)]
25 pub created: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct RecentView {
31 #[serde(flatten)]
33 pub recent: Recent,
34 pub shown: String,
36 pub exists: bool,
38 pub undo: Option<speccoder_core::new_project::UndoOffer>,
40}
41
42fn file(state: &Path) -> PathBuf {
43 state.join("recent.json")
44}
45
46fn now() -> u64 {
47 std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map_or(0, |d| d.as_secs())
50}
51
52#[must_use]
54pub fn recent(state: &Path) -> Vec<Recent> {
55 std::fs::read_to_string(file(state))
56 .ok()
57 .and_then(|t| serde_json::from_str(&t).ok())
58 .unwrap_or_default()
59}
60
61fn save(state: &Path, list: &[Recent]) {
62 let f = file(state);
63 if let Some(p) = f.parent() {
64 let _ = std::fs::create_dir_all(p);
65 }
66 if let Ok(text) = serde_json::to_string_pretty(list) {
67 let tmp = f.with_extension("json.tmp");
68 if std::fs::write(&tmp, text).is_ok() {
69 let _ = std::fs::rename(&tmp, &f);
70 }
71 }
72}
73
74#[must_use]
78pub fn is_project(path: &Path) -> bool {
79 crate::format::open(path, None).is_ok()
80 || crate::git::Git::at(path)
81 .toplevel()
82 .ok()
83 .flatten()
84 .is_some()
85}
86
87pub fn remember(state: &Path, root: &Path, name: &str, created: bool) {
89 let path = std::fs::canonicalize(root)
90 .unwrap_or_else(|_| root.to_path_buf())
91 .display()
92 .to_string();
93 let mut list = recent(state);
94 let was_created = list.iter().any(|r| r.path == path && r.created);
95 list.retain(|r| r.path != path);
96 list.insert(
97 0,
98 Recent {
99 path,
100 name: name.to_owned(),
101 opened_at: now(),
102 created: created || was_created,
103 },
104 );
105 list.truncate(MAX_RECENT);
106 save(state, &list);
107}
108
109pub fn forget(state: &Path, root: &Path) {
111 let path = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
112 let mut list = recent(state);
113 list.retain(|r| Path::new(&r.path) != path && r.path != root.display().to_string());
114 save(state, &list);
115}
116
117#[must_use]
119pub fn tilde(p: &Path) -> String {
120 match home() {
121 Some(h) if p.starts_with(&h) => match p.strip_prefix(&h) {
122 Ok(rest) if rest.as_os_str().is_empty() => "~".to_owned(),
123 Ok(rest) => format!("~/{}", rest.display()),
124 Err(_) => p.display().to_string(),
125 },
126 _ => p.display().to_string(),
127 }
128}
129
130fn home() -> Option<PathBuf> {
131 std::env::var_os("HOME")
132 .map(PathBuf::from)
133 .map(|h| std::fs::canonicalize(&h).unwrap_or(h))
134}
135
136#[must_use]
139pub fn views(state: &Path) -> Vec<RecentView> {
140 let day = 24 * 60 * 60;
141 recent(state)
142 .into_iter()
143 .map(|r| {
144 let p = PathBuf::from(&r.path);
145 let exists = p.is_dir();
146 let made_at = crate::new_project::creation(state, &p).map(|c| c.at);
148 let undo = (exists && made_at.is_some_and(|at| now().saturating_sub(at) < day))
149 .then(|| crate::new_project::undo_offer(state, &p))
150 .filter(|u| u.available);
151 RecentView {
152 shown: tilde(&p),
153 exists,
154 undo,
155 recent: r,
156 }
157 })
158 .collect()
159}
160
161#[must_use]
164pub fn default_parent(state: &Path) -> PathBuf {
165 if let Some(parent) = recent(state)
166 .into_iter()
167 .filter(|r| r.created)
168 .find_map(|r| Path::new(&r.path).parent().map(Path::to_path_buf))
169 .filter(|p| p.is_dir())
170 {
171 return parent;
172 }
173 match home() {
174 Some(h) if h.join("Workspaces").is_dir() => h.join("Workspaces"),
175 Some(h) => h,
176 None => PathBuf::from("."),
177 }
178}
179
180pub fn folders(under: &str) -> Result<Vec<String>, String> {
189 let home = home().ok_or_else(|| "no home directory".to_owned())?;
190 let expanded = match under.strip_prefix('~') {
191 Some(rest) => home.join(rest.trim_start_matches('/')),
192 None if under.is_empty() => home.clone(),
193 None => PathBuf::from(under),
194 };
195 if !expanded.is_absolute() {
196 return Err("give a path under your home folder".to_owned());
197 }
198 let (dir, prefix) = if under.ends_with('/') || expanded.is_dir() {
199 (expanded.clone(), String::new())
200 } else {
201 (
202 expanded
203 .parent()
204 .map_or_else(|| expanded.clone(), Path::to_path_buf),
205 expanded
206 .file_name()
207 .map(|n| n.to_string_lossy().into_owned())
208 .unwrap_or_default(),
209 )
210 };
211 let dir = std::fs::canonicalize(&dir).map_err(|_| "not a folder".to_owned())?;
212 if !dir.starts_with(&home) {
213 return Err("only folders under your home folder are listed".to_owned());
214 }
215 let hidden = prefix.starts_with('.');
216 let mut names: Vec<String> = std::fs::read_dir(&dir)
217 .map_err(|_| "not readable".to_owned())?
218 .flatten()
219 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
220 .map(|e| e.file_name().to_string_lossy().into_owned())
221 .filter(|n| hidden || !n.starts_with('.'))
222 .filter(|n| n.starts_with(&prefix))
223 .collect();
224 names.sort_by_key(|n| n.to_lowercase());
225 names.truncate(MAX_FOLDERS);
226 Ok(names.into_iter().map(|n| tilde(&dir.join(n))).collect())
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn recent_projects_are_newest_first_once_each_and_forgotten_on_request() {
235 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
236 let a = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
237 let b = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
238 remember(state.path(), a.path(), "a", true);
239 remember(state.path(), b.path(), "b", false);
240 remember(state.path(), a.path(), "a", false);
241 let r = recent(state.path());
242 assert_eq!(
243 r.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
244 ["a", "b"]
245 );
246 assert!(r[0].created, "created stays");
247 forget(state.path(), a.path());
248 assert_eq!(recent(state.path()).len(), 1);
249 }
250
251 #[test]
252 fn undo_is_offered_a_day_from_the_creation_not_from_the_last_open() {
253 let run = |dir: &Path, args: &[&str]| {
254 let ok = std::process::Command::new("git")
255 .args([
256 "-c",
257 "user.name=t",
258 "-c",
259 "user.email=t@example.com",
260 "-c",
261 "commit.gpgsign=false",
262 ])
263 .args(args)
264 .current_dir(dir)
265 .status()
266 .is_ok_and(|s| s.success());
267 assert!(ok, "git {args:?}");
268 };
269 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
270 let repo = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
271 let root = std::fs::canonicalize(repo.path()).unwrap_or_else(|e| panic!("{e}"));
272 run(&root, &["init", "-q"]);
273 std::fs::write(root.join("a"), "a\n").unwrap_or_else(|e| panic!("{e}"));
274 run(&root, &["add", "-A"]);
275 run(&root, &["commit", "-qm", "new project: x"]);
276 let head = crate::git::Git::at(&root).head().ok().flatten();
277 let record = crate::new_project::Creation {
278 path: root.display().to_string(),
279 mode: speccoder_core::new_project::ProjectMode::ExistingRepository,
280 commit: head,
281 plan_digest: String::new(),
282 name: "x".to_owned(),
283 idea: String::new(),
284 stack: speccoder_core::new_project::Stack::None,
285 format: "speckit".to_owned(),
286 by: None,
287 via: speccoder_core::actions::Via::Terminal,
288 at: now() - 2 * 24 * 60 * 60,
289 first_feature_offered: false,
290 undone_at: None,
291 before: crate::new_project::Before::default(),
292 left: None,
293 git: None,
294 };
295 crate::new_project::write_creation(state.path(), &record).unwrap_or_else(|e| panic!("{e}"));
296 remember(state.path(), &root, "x", true);
298 let v = views(state.path());
299 assert_eq!(v.len(), 1);
300 assert!(v[0].undo.is_none(), "{:?}", v[0].undo);
301 }
302}