Skip to main content

xil_core/
workspace.rs

1//! Workspace root, show slug, active show, and the two on-disk layouts.
2//!
3//! Port of the path half of `models.py`.
4
5use std::collections::BTreeMap;
6use std::env;
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13/// Fallback slug when neither `--show` nor `project.json` names one.
14pub const DEFAULT_SLUG: &str = "sample";
15
16/// Per-type production defaults: gap between dialogue stems and a stability
17/// hint. `stability` is `None` where Python has `None`.
18pub fn type_defaults(kind: &str) -> Option<(u32, Option<f64>)> {
19    match kind {
20        "podcast" => Some((600, None)),
21        "audiobook" => Some((400, Some(0.75))),
22        "drama" => Some((800, None)),
23        "special" => Some((600, None)),
24        _ => None,
25    }
26}
27
28/// `~` expansion the way `Path.expanduser()` does it: only a leading `~`.
29fn expanduser(p: &str) -> PathBuf {
30    if let Some(rest) = p.strip_prefix('~') {
31        if rest.is_empty() || rest.starts_with('/') {
32            if let Some(home) = env::var_os("HOME") {
33                return PathBuf::from(home).join(rest.trim_start_matches('/'));
34            }
35        }
36    }
37    PathBuf::from(p)
38}
39
40/// `Path.resolve()` — absolute, symlinks followed when the path exists.
41fn resolve(p: PathBuf) -> PathBuf {
42    match fs::canonicalize(&p) {
43        Ok(c) => c,
44        Err(_) => std::path::absolute(&p).unwrap_or(p),
45    }
46}
47
48/// The active workspace root: `$XIL_PROJECTROOT` (expanded, resolved), else
49/// the current directory.
50pub fn workspace_root() -> PathBuf {
51    match env::var("XIL_PROJECTROOT") {
52        Ok(v) if !v.is_empty() => resolve(expanduser(&v)),
53        _ => env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
54    }
55}
56
57/// `$XIL_CODEROOT` — where the software and the heavy model venvs live.
58pub fn code_root() -> Option<PathBuf> {
59    match env::var("XIL_CODEROOT") {
60        Ok(v) if !v.is_empty() => Some(resolve(expanduser(&v))),
61        _ => None,
62    }
63}
64
65/// Slug from `.active_show`, or `None` when the file is absent.
66pub fn active_show() -> Option<String> {
67    let f = workspace_root().join(".active_show");
68    fs::read_to_string(f).ok().map(|s| s.trim().to_string())
69}
70
71/// Write `slug` to `.active_show` in the workspace root (no trailing newline,
72/// same as Python's `write_text`).
73pub fn set_active_show(slug: &str) -> io::Result<()> {
74    fs::write(workspace_root().join(".active_show"), slug)
75}
76
77/// Show title → filesystem-safe slug: lowercase, keep only `[a-z0-9]`.
78///
79/// Python lowercases with `str.lower()`, which is Unicode-aware; the filter
80/// then drops anything outside ASCII alphanumerics, so the result is the
81/// same either way.
82pub fn show_slug(show_name: &str) -> String {
83    show_name
84        .to_lowercase()
85        .chars()
86        .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
87        .collect()
88}
89
90/// Every standard pipeline path for one episode, keyed by the same logical
91/// names as the Python dict.
92pub type Paths = BTreeMap<&'static str, PathBuf>;
93
94/// Normalized layout (0.1.8+).
95fn derive_paths_new(root: &Path, slug: &str, tag: &str) -> Paths {
96    let mut p = Paths::new();
97    p.insert(
98        "cast",
99        root.join("configs")
100            .join(slug)
101            .join(format!("cast_{tag}.json")),
102    );
103    p.insert(
104        "sfx",
105        root.join("configs")
106            .join(slug)
107            .join(format!("sfx_{tag}.json")),
108    );
109    p.insert(
110        "parsed",
111        root.join("parsed")
112            .join(slug)
113            .join(format!("parsed_{tag}.json")),
114    );
115    p.insert(
116        "parsed_csv",
117        root.join("parsed")
118            .join(slug)
119            .join(format!("parsed_{tag}.csv")),
120    );
121    p.insert(
122        "annotated_csv",
123        root.join("parsed")
124            .join(slug)
125            .join(format!("annotated_{tag}.csv")),
126    );
127    p.insert(
128        "master",
129        root.join("masters")
130            .join(slug)
131            .join(format!("{tag}_master.mp3")),
132    );
133    p.insert(
134        "cues",
135        root.join("cues").join(slug).join(format!("cues_{tag}.md")),
136    );
137    p.insert(
138        "cues_manifest",
139        root.join("cues")
140            .join(slug)
141            .join(format!("cues_manifest_{tag}.json")),
142    );
143    p.insert(
144        "orig_parsed",
145        root.join("parsed")
146            .join(slug)
147            .join(format!("orig_parsed_{tag}.json")),
148    );
149    p.insert(
150        "revised_script",
151        root.join("scripts")
152            .join(slug)
153            .join(format!("revised_{slug}_{tag}.md")),
154    );
155    p.insert("stems", root.join("stems").join(slug).join(tag));
156    p.insert("daw", root.join("daw").join(slug).join(tag));
157    p.insert(
158        "posts",
159        root.join("posts")
160            .join(slug)
161            .join(format!("{tag}_posts.md")),
162    );
163    p
164}
165
166/// Legacy layout (pre-0.1.8) — what `xil migrate-workspace` moves away from.
167pub fn derive_paths_legacy(slug: &str, tag: &str) -> Paths {
168    let root = workspace_root();
169    let mut p = Paths::new();
170    p.insert("cast", root.join(format!("cast_{slug}_{tag}.json")));
171    p.insert("sfx", root.join(format!("sfx_{slug}_{tag}.json")));
172    p.insert(
173        "parsed",
174        root.join("parsed")
175            .join(format!("parsed_{slug}_{tag}.json")),
176    );
177    p.insert(
178        "parsed_csv",
179        root.join("parsed").join(format!("parsed_{slug}_{tag}.csv")),
180    );
181    p.insert(
182        "annotated_csv",
183        root.join("parsed")
184            .join(format!("parsed_{slug}_{tag}_annotated.csv")),
185    );
186    p.insert("master", root.join(format!("{slug}_{tag}_master.mp3")));
187    p.insert(
188        "cues",
189        root.join("cues").join(format!("cues_{slug}_{tag}.md")),
190    );
191    p.insert(
192        "cues_manifest",
193        root.join("cues").join(format!("cues_manifest_{tag}.json")),
194    );
195    p.insert(
196        "orig_parsed",
197        root.join("parsed")
198            .join(format!("orig_parsed_{slug}_{tag}.json")),
199    );
200    p.insert(
201        "revised_script",
202        root.join("scripts")
203            .join(format!("revised_{slug}_{tag}.md")),
204    );
205    p.insert("stems", root.join("stems").join(slug).join(tag));
206    p.insert("daw", root.join("daw").join(tag));
207    p
208}
209
210/// Auto-detect the layout: legacy when the cast config exists only at the
211/// legacy root location, normalized otherwise.
212pub fn derive_paths(slug: &str, tag: &str) -> Paths {
213    let new = derive_paths_new(&workspace_root(), slug, tag);
214    let legacy = derive_paths_legacy(slug, tag);
215    let use_legacy = legacy["cast"].exists() && !new["cast"].exists();
216    if use_legacy {
217        legacy
218    } else {
219        new
220    }
221}
222
223/// `resolve_venv_python(venv_name, explicit)` — the interpreter of an
224/// optional local-model venv (`venv-whisper`, `venv-chatterbox`, …).
225///
226/// An explicit path wins. With `XIL_CODEROOT` set, only
227/// `$XIL_CODEROOT/<venv>/bin/python3` is considered. Otherwise the
228/// workspace root, then the repo root above the Python package
229/// (`package_dir/../..`), are tried.
230pub fn resolve_venv_python(
231    venv_name: &str,
232    explicit: Option<&str>,
233    package_dir: Option<&Path>,
234) -> Option<String> {
235    if let Some(e) = explicit.filter(|e| !e.is_empty()) {
236        return Some(e.to_string());
237    }
238    if let Some(root) = code_root() {
239        let cand = root.join(venv_name).join("bin").join("python3");
240        return cand.exists().then(|| cand.to_string_lossy().into_owned());
241    }
242    let mut candidates = vec![workspace_root().join(venv_name).join("bin").join("python3")];
243    if let Some(repo) = package_dir.and_then(Path::parent).and_then(Path::parent) {
244        candidates.push(repo.join(venv_name).join("bin").join("python3"));
245    }
246    candidates
247        .into_iter()
248        .find(|c| c.exists())
249        .map(|c| c.to_string_lossy().into_owned())
250}
251
252/// Parsed `project.json`, or an empty object when absent.
253///
254/// A relative `project_path` is anchored at the workspace root. The bare
255/// default name additionally honours `.active_show`: when that names a slug
256/// with its own `configs/<slug>/project.json`, that file wins.
257pub fn read_project(project_path: &str) -> Value {
258    let mut path = PathBuf::from(project_path);
259    if !path.is_absolute() {
260        let root = workspace_root();
261        if project_path == "project.json" {
262            if let Some(slug) = active_show() {
263                let candidate = root.join("configs").join(&slug).join("project.json");
264                if candidate.exists() {
265                    if let Some(v) = read_json_object(&candidate) {
266                        return v;
267                    }
268                }
269            }
270        }
271        path = root.join(path);
272    }
273    if path.exists() {
274        if let Some(v) = read_json_object(&path) {
275            return v;
276        }
277    }
278    Value::Object(Default::default())
279}
280
281fn read_json_object(path: &Path) -> Option<Value> {
282    let text = fs::read_to_string(path).ok()?;
283    serde_json::from_str(&text).ok()
284}
285
286/// Content type from `project.json`, defaulting to `"podcast"`.
287pub fn resolve_project_type(project_path: &str) -> String {
288    read_project(project_path)
289        .get("type")
290        .and_then(Value::as_str)
291        .unwrap_or("podcast")
292        .to_string()
293}
294
295/// Slug from `--show`, else `project.json`'s `show`, else [`DEFAULT_SLUG`].
296pub fn resolve_slug(show_arg: Option<&str>, project_path: &str) -> String {
297    if let Some(s) = show_arg.filter(|s| !s.is_empty()) {
298        return show_slug(s);
299    }
300    match read_project(project_path).get("show") {
301        Some(Value::String(s)) => show_slug(s),
302        Some(other) => show_slug(&python_str(other)),
303        None => DEFAULT_SLUG.to_string(),
304    }
305}
306
307/// Season/arc title from an explicit value, else `project.json`, else `None`.
308/// An empty string in the file counts as absent (Python: `or None`).
309pub fn resolve_season_title(arg: Option<&str>, project_path: &str) -> Option<String> {
310    if let Some(a) = arg {
311        return Some(a.to_string());
312    }
313    match read_project(project_path).get("season_title") {
314        Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
315        _ => None,
316    }
317}
318
319/// Season number from an explicit value, else `project.json`, else `None`.
320pub fn resolve_season(arg: Option<i64>, project_path: &str) -> Option<i64> {
321    if arg.is_some() {
322        return arg;
323    }
324    match read_project(project_path).get("season") {
325        Some(Value::Number(n)) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
326        Some(Value::String(s)) => s.trim().parse().ok(),
327        _ => None,
328    }
329}
330
331/// `S01E01` when a season is known, `E01` otherwise.
332pub fn episode_tag(season: Option<i64>, episode: i64) -> String {
333    match season {
334        Some(s) => format!("S{s:02}E{episode:02}"),
335        None => format!("E{episode:02}"),
336    }
337}
338
339/// How Python's `str()` would render a scalar JSON value. Only the cases
340/// that can plausibly land in a config file are covered.
341pub fn python_str(v: &Value) -> String {
342    match v {
343        Value::Null => "None".to_string(),
344        Value::Bool(true) => "True".to_string(),
345        Value::Bool(false) => "False".to_string(),
346        Value::Number(n) => n.to_string(),
347        Value::String(s) => s.clone(),
348        other => other.to_string(),
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn slug_strips_everything_but_ascii_alnum() {
358        assert_eq!(show_slug("THE 413"), "the413");
359        assert_eq!(show_slug("Night Owls!"), "nightowls");
360        assert_eq!(show_slug("Café-Über 9"), "cafber9");
361        assert_eq!(show_slug(""), "");
362    }
363
364    #[test]
365    fn episode_tag_formats() {
366        assert_eq!(episode_tag(Some(1), 1), "S01E01");
367        assert_eq!(episode_tag(Some(12), 7), "S12E07");
368        assert_eq!(episode_tag(None, 3), "E03");
369        assert_eq!(episode_tag(Some(100), 100), "S100E100");
370    }
371
372    #[test]
373    fn new_layout_paths() {
374        let p = derive_paths_new(Path::new("/ws"), "the413", "S01E01");
375        assert_eq!(p["cast"], Path::new("/ws/configs/the413/cast_S01E01.json"));
376        assert_eq!(
377            p["parsed"],
378            Path::new("/ws/parsed/the413/parsed_S01E01.json")
379        );
380        assert_eq!(
381            p["revised_script"],
382            Path::new("/ws/scripts/the413/revised_the413_S01E01.md")
383        );
384        assert_eq!(p["daw"], Path::new("/ws/daw/the413/S01E01"));
385        assert_eq!(p["posts"], Path::new("/ws/posts/the413/S01E01_posts.md"));
386        assert_eq!(p.len(), 13);
387    }
388
389    // $HOME is a Unix variable; Windows uses USERPROFILE and this would
390    // panic on the unwrap. Tilde expansion only matters where xil runs.
391    #[test]
392    #[cfg(unix)]
393    fn expanduser_only_leading_tilde() {
394        let home = env::var("HOME").unwrap();
395        assert_eq!(expanduser("~/x"), Path::new(&home).join("x"));
396        assert_eq!(expanduser("~"), Path::new(&home));
397        assert_eq!(expanduser("/a/~/b"), Path::new("/a/~/b"));
398        assert_eq!(expanduser("~bob/x"), Path::new("~bob/x"));
399    }
400
401    #[test]
402    fn python_str_renders_scalars() {
403        assert_eq!(python_str(&Value::Null), "None");
404        assert_eq!(python_str(&Value::Bool(true)), "True");
405        assert_eq!(python_str(&serde_json::json!(3)), "3");
406        assert_eq!(python_str(&serde_json::json!("x")), "x");
407    }
408}