1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8
9use crate::layout::{self, Plan};
10
11pub 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
25pub 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
34pub fn summary_of(plan: &Path) -> PathBuf {
36 plan.with_extension("md")
37}
38
39pub fn parked(progress: &Path, slug: &str) -> PathBuf {
41 progress
42 .with_file_name("plans")
43 .join(format!("{slug}.json"))
44}
45
46pub fn fresh(progress: &Path) -> bool {
48 !layout::plan_path().exists() && !progress.exists()
49}
50
51pub 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
64pub 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
83pub 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
93pub 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 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, ¤t);
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
158fn 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
167pub 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}