1use std::collections::BTreeMap;
6use std::env;
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13pub const DEFAULT_SLUG: &str = "sample";
15
16pub 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
28fn 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
40fn 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
48pub 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
57pub 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
65pub 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
71pub fn set_active_show(slug: &str) -> io::Result<()> {
74 fs::write(workspace_root().join(".active_show"), slug)
75}
76
77pub 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
90pub type Paths = BTreeMap<&'static str, PathBuf>;
93
94fn 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
166pub 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
210pub 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
223pub 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
252pub 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
286pub 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
295pub 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
307pub 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
319pub 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
331pub 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
339pub 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 #[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}