Skip to main content

very_normal/
plans.rs

1//! The plans you've made, and switching between them. Making a plan never
2//! touches the one you're practising; `use` switches, and keeps each plan's
3//! progress separately so stage numbers never get mixed up between plans.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8
9use crate::layout::{self, Plan};
10
11/// A file name for a plan's name: `Home Row Hero` is `home-row-hero`.
12pub fn slug(name: &str) -> String {
13    let mut out = String::new();
14    for c in name.chars().flat_map(char::to_lowercase) {
15        if c.is_alphanumeric() {
16            out.push(c);
17        } else if !out.is_empty() && !out.ends_with('-') {
18            out.push('-');
19        }
20    }
21    let out = out.trim_end_matches('-').to_string();
22    if out.is_empty() { "plan".into() } else { out }
23}
24
25/// Where made plans and their summaries live: next to the active plan.
26pub fn dir() -> PathBuf {
27    layout::plan_path().with_file_name("plans")
28}
29
30pub fn made_path(name: &str) -> PathBuf {
31    dir().join(format!("{}.toml", slug(name)))
32}
33
34/// A plan's shareable summary sits next to it.
35pub fn summary_of(plan: &Path) -> PathBuf {
36    plan.with_extension("md")
37}
38
39/// Where a plan's progress waits while you practise another one.
40pub fn parked(progress: &Path, slug: &str) -> PathBuf {
41    progress
42        .with_file_name("plans")
43        .join(format!("{slug}.json"))
44}
45
46/// Nothing to lose yet: no plan of your own and no practice recorded.
47pub fn fresh(progress: &Path) -> bool {
48    !layout::plan_path().exists() && !progress.exists()
49}
50
51/// The plan you're practising.
52pub fn active_slug() -> String {
53    slug(&layout::plan().name)
54}
55
56fn builtin_slug() -> String {
57    slug(
58        &Plan::parse(layout::EXAMPLE)
59            .expect("the example plan is valid")
60            .name,
61    )
62}
63
64/// The plans you've made, by slug, plus the built-in one.
65pub fn list() -> Vec<String> {
66    let mut out: Vec<String> = std::fs::read_dir(dir())
67        .into_iter()
68        .flatten()
69        .flatten()
70        .filter_map(|e| {
71            let p = e.path();
72            (p.extension()? == "toml").then(|| p.file_stem()?.to_str().map(String::from))?
73        })
74        .collect();
75    let builtin = builtin_slug();
76    if !out.contains(&builtin) {
77        out.push(builtin);
78    }
79    out.sort();
80    out
81}
82
83/// Makes a plan the active one. Only for a fresh start: nothing to park.
84pub fn activate(made: &Path) -> Result<()> {
85    let to = layout::plan_path();
86    if let Some(d) = to.parent() {
87        std::fs::create_dir_all(d)?;
88    }
89    std::fs::copy(made, &to).with_context(|| format!("writing {}", to.display()))?;
90    Ok(())
91}
92
93/// Switches to another plan: parks this plan's progress, and brings back
94/// the other plan's progress if you've practised it before. If you haven't,
95/// and its stages are exactly this plan's, your progress carries over.
96pub fn switch(target: &str, progress: &Path) -> Result<String> {
97    let current = active_slug();
98    let want = slug(target);
99    if want == current {
100        bail!("you're already practising {}", layout::plan().name);
101    }
102    let made = dir().join(format!("{want}.toml"));
103    let builtin = want == builtin_slug() && !made.exists();
104    if !builtin && !made.exists() {
105        bail!("no plan called {target}; you have: {}", list().join(", "));
106    }
107    let plan = if builtin {
108        Plan::parse(layout::EXAMPLE)?
109    } else {
110        Plan::load(&made)?
111    };
112
113    // Keep a plan you wrote by hand, which has no copy in plans/ yet.
114    let active = layout::plan_path();
115    let keep = dir().join(format!("{current}.toml"));
116    if active.exists() && !keep.exists() {
117        std::fs::create_dir_all(dir())?;
118        std::fs::copy(&active, &keep).with_context(|| format!("keeping {}", keep.display()))?;
119    }
120
121    let back = parked(progress, &want);
122    let resumed = back.exists();
123    let carried = !resumed && progress.exists() && same_stages(layout::plan(), &plan);
124    if progress.exists() && !carried {
125        let to = parked(progress, &current);
126        std::fs::create_dir_all(to.parent().expect("has a parent"))?;
127        std::fs::rename(progress, &to).with_context(|| format!("parking {}", to.display()))?;
128    }
129    if resumed {
130        std::fs::rename(&back, progress)
131            .with_context(|| format!("restoring {}", back.display()))?;
132    }
133
134    if builtin {
135        if active.exists() {
136            std::fs::remove_file(&active)?;
137        }
138    } else {
139        activate(&made)?;
140    }
141    if carried {
142        return Ok(format!(
143            "Now practising {}. Its stages are the same as {current}'s, so your progress came with you.",
144            plan.name
145        ));
146    }
147    Ok(format!(
148        "Now practising {}{}. Your progress on {current} is kept for when you switch back.",
149        plan.name,
150        if resumed {
151            ", where you left off"
152        } else {
153            " from the start"
154        }
155    ))
156}
157
158/// Two plans whose stages have exactly the same keys share progress.
159fn same_stages(a: &Plan, b: &Plan) -> bool {
160    a.stages.len() == b.stages.len()
161        && a.stages
162            .iter()
163            .zip(&b.stages)
164            .all(|(x, y)| x.keys == y.keys)
165}
166
167/// Deletes a plan you made, its summary, and any progress kept for it.
168pub fn remove(name: &str, progress: &Path) -> Result<String> {
169    let want = slug(name);
170    if want == active_slug() {
171        bail!(
172            "{} is the plan you're practising; switch to another with `very-normal use` first",
173            layout::plan().name
174        );
175    }
176    let made = dir().join(format!("{want}.toml"));
177    if !made.exists() {
178        if want == builtin_slug() {
179            bail!("the built-in plan can't be removed");
180        }
181        bail!("no plan called {name}; you have: {}", list().join(", "));
182    }
183    let mut gone = vec![made.clone()];
184    std::fs::remove_file(&made)?;
185    for extra in [summary_of(&made), parked(progress, &want)] {
186        if extra.exists() {
187            std::fs::remove_file(&extra)?;
188            gone.push(extra);
189        }
190    }
191    Ok(format!(
192        "Removed {}.",
193        gone.iter()
194            .map(|p| p.display().to_string())
195            .collect::<Vec<_>>()
196            .join(", ")
197    ))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn slugs_are_file_names() {
206        assert_eq!(slug("Home Row Hero"), "home-row-hero");
207        assert_eq!(slug("  Very  Normal! "), "very-normal");
208        assert_eq!(slug("Mark's"), "mark-s");
209        assert_eq!(slug("!!"), "plan");
210    }
211}