1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
//! Filesystem-derived routine location helpers.
//!
//! Routine organization is owned by the directory tree under `routines/`, not by duplicated TOML
//! metadata. These helpers find a routine's current relative directory by reading `routine.toml`
//! ids and fall back to the title slug only for new routines that have never been persisted.
use serde::Deserialize;
use crate::paths::routines_dir;
use crate::routines::{slugify, Routine};
#[derive(Deserialize)]
/// Minimal TOML view used to match a persisted routine directory by stable id.
struct IdOnlyRoutineToml {
/// Stable routine id from `routine.toml`.
id: Option<String>,
}
/// Convert a relative path to the API's slash-separated representation.
fn rel_string(path: &std::path::Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
/// Read only the stable routine id from a `routine.toml` path.
fn read_id(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str::<IdOnlyRoutineToml>(&text).ok())
.and_then(|routine| routine.id)
}
/// Recursively find the directory whose `routine.toml` contains `id`.
fn find_rel_dir_by_id(base: &std::path::Path, dir: &std::path::Path, id: &str) -> Option<String> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let path = entry.path();
let toml = path.join("routine.toml");
if toml.exists() && read_id(&toml).as_deref() == Some(id) {
return path.strip_prefix(base).ok().map(rel_string);
}
if let Some(found) = find_rel_dir_by_id(base, &path, id) {
return Some(found);
}
}
None
}
/// Return the current routine directory relative to `routines/`, if a persisted routine with `id`
/// exists anywhere under the recursive routines tree.
pub(crate) fn routine_rel_dir_by_id(id: &str) -> Option<String> {
let base = routines_dir();
find_rel_dir_by_id(&base, &base, id)
}
/// Return the relative directory a routine should use for filesystem sidecars.
///
/// Existing routines keep their current disk location. New routines fall back to the slugified title.
pub(crate) fn routine_rel_dir(routine: &Routine) -> String {
routine_rel_dir_by_id(&routine.id).unwrap_or_else(|| slugify(&routine.title))
}
/// Return the last path segment of a routine's filesystem location.
pub(crate) fn routine_slug(routine: &Routine) -> String {
slug_from_rel_dir(&routine_rel_dir(routine))
}
/// Return the last path segment of an already-computed [`routine_rel_dir`] value.
///
/// Pure string derivation — no filesystem access. Prefer this over [`routine_slug`] when a
/// `rel_dir` has already been computed for the same routine, so callers that need both the
/// directory and its slug (or folder) don't each trigger their own recursive tree walk.
pub(crate) fn slug_from_rel_dir(rel_dir: &str) -> String {
rel_dir.rsplit('/').next().unwrap_or("").to_string()
}
/// Return the parent folder of an already-computed [`routine_rel_dir`] value, relative to
/// `routines/`.
///
/// Pure string derivation — no filesystem access. See [`slug_from_rel_dir`] for why this exists
/// as a companion for callers that already computed a relative directory.
pub(crate) fn folder_from_rel_dir(rel_dir: &str) -> Option<String> {
std::path::Path::new(rel_dir)
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(rel_string)
}