Skip to main content

speccoder_runtime/
welcome.rs

1//! What a host started outside a project shows: the recent projects
2//! (speccoder's own state, written when a project is opened or created)
3//! and folder names to choose from — read-only, under the person's home.
4
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9/// The most recent projects kept.
10pub const MAX_RECENT: usize = 20;
11/// The most folder names one listing returns.
12pub const MAX_FOLDERS: usize = 200;
13
14/// A recent project.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Recent {
17    /// Its folder, canonical.
18    pub path: String,
19    /// Its name.
20    pub name: String,
21    /// Unix seconds it was last opened.
22    pub opened_at: u64,
23    /// speccoder created it.
24    #[serde(default)]
25    pub created: bool,
26}
27
28/// A recent project as the welcome shows it.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct RecentView {
31    /// The record.
32    #[serde(flatten)]
33    pub recent: Recent,
34    /// The folder, `~`-abbreviated.
35    pub shown: String,
36    /// The folder is still there.
37    pub exists: bool,
38    /// Undo is offered on it (created within the last day, nothing else happened).
39    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/// The recent projects, newest first.
53#[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/// Whether a host started at `path` serves it right away: a spec project,
75/// or a git repository (the setup assistant takes over there). Anywhere
76/// else the host serves the welcome.
77#[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
87/// Records that the project at `root` was opened (or created) now.
88pub 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
109/// Removes the project at `root` from the recent ones.
110pub 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/// `~/x` for a path under the home directory.
118#[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/// The recent projects with whether each folder is still there (nothing
137/// of a missing one is read) and Undo on a project created in the last day.
138#[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            // A day from the creation, not from the last time it was opened.
147            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/// Where New Project puts a folder by default: beside the last project
162/// speccoder created, else `~/Workspaces` when it exists, else home.
163#[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
180/// Folder names under `under` (which must lie in the person's home):
181/// directories only, hidden ones only when `under`'s last part asks for
182/// them, at most [`MAX_FOLDERS`]. `under` may end in a partial name: the
183/// folders of its parent that start with it.
184///
185/// # Errors
186///
187/// `under` lies outside the home directory, or there is no home.
188pub 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        // Opened again just now: still two days after it was made.
297        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}