1use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::sync::LazyLock;
7
8use regex::Regex;
9use serde_json::Value;
10use xil_core::fsutil::{basename, glob_children};
11use xil_core::pyjson::{dumps, Style};
12use xil_core::workspace::{active_show, show_slug, workspace_root};
13
14use crate::activity;
15use crate::episodes::{child_dirs, is_legacy_cast_name};
16
17static LEGACY_SFX_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^sfx_(.+?)_([A-Z0-9]+)\.json$").unwrap());
19
20fn strings(paths: Vec<PathBuf>) -> Vec<String> {
21 paths
22 .into_iter()
23 .map(|p| p.to_string_lossy().into_owned())
24 .collect()
25}
26
27pub fn find_speakers_configs() -> Vec<String> {
29 let root = workspace_root();
30 let mut v: Vec<PathBuf> = child_dirs(&root.join("configs"))
31 .into_iter()
32 .map(|d| d.join("speakers.json"))
33 .filter(|p| p.is_file())
34 .collect();
35 let legacy = root.join("speakers.json");
36 if legacy.exists() {
37 v.push(legacy);
38 }
39 strings(v)
40}
41
42pub fn find_cast_configs() -> Vec<String> {
44 let root = workspace_root();
45 let mut v = Vec::new();
46 for d in child_dirs(&root.join("configs")) {
47 v.extend(glob_children(&d, "cast_", ".json"));
48 }
49 v.sort();
50 v.extend(
51 glob_children(&root, "cast_", ".json")
52 .into_iter()
53 .filter(|p| is_legacy_cast_name(&basename(p))),
54 );
55 strings(v)
56}
57
58pub fn find_sfx_configs() -> Vec<String> {
60 let root = workspace_root();
61 let mut v = Vec::new();
62 for d in child_dirs(&root.join("configs")) {
63 v.extend(glob_children(&d, "sfx_", ".json"));
64 }
65 v.sort();
66 v.extend(
67 glob_children(&root, "sfx_", ".json")
68 .into_iter()
69 .filter(|p| LEGACY_SFX_RE.is_match(&basename(p))),
70 );
71 strings(v)
72}
73
74pub fn resolve_lenient(path: &Path) -> PathBuf {
77 let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
78 let mut existing = abs.clone();
79 let mut rest = Vec::new();
80 while !existing.exists() {
81 match (existing.parent(), existing.file_name()) {
82 (Some(parent), Some(name)) => {
83 rest.push(name.to_os_string());
84 existing = parent.to_path_buf();
85 }
86 _ => break,
87 }
88 }
89 let mut out = fs::canonicalize(&existing).unwrap_or(existing);
90 for name in rest.into_iter().rev() {
91 out.push(name);
92 }
93 let mut norm = PathBuf::new();
94 for c in out.components() {
95 match c {
96 Component::ParentDir => {
97 norm.pop();
98 }
99 Component::CurDir => {}
100 other => norm.push(other),
101 }
102 }
103 norm
104}
105
106pub fn check_workspace_path(path: &Path) -> Result<(), String> {
109 let workspace = resolve_lenient(&workspace_root());
110 if resolve_lenient(path).starts_with(&workspace) {
111 Ok(())
112 } else {
113 Err(format!(
114 "Path is outside the workspace root: {}",
115 xil_core::script::hints::py_repr(&path.to_string_lossy())
116 ))
117 }
118}
119
120pub fn load_config_file(path: &str, label: &str) -> String {
123 if path.is_empty() {
124 return String::new();
125 }
126 if let Err(e) = check_workspace_path(Path::new(path)) {
127 return format!("// {e}");
128 }
129 activity::log(&format!("SELECT {label} → {path}"));
130 if !Path::new(path).exists() {
131 return format!("// File not found: {path}");
132 }
133 fs::read_to_string(path).unwrap_or_else(|e| format!("// {e}"))
134}
135
136fn write_json(path: &Path, data: &Value) -> std::io::Result<()> {
138 fs::write(path, dumps(data, Style::INDENT2) + "\n")
139}
140
141pub fn save_config_file(path: &str, text: &str, what: &str) -> String {
144 if path.is_empty() {
145 return "No file selected.".into();
146 }
147 let p = Path::new(path);
148 if let Err(e) = check_workspace_path(p) {
149 return e;
150 }
151 let data: Value = match serde_json::from_str(text) {
152 Ok(d) => d,
153 Err(e) => return format!("Invalid JSON — not saved: {e}"),
154 };
155 if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
156 if let Err(e) = fs::create_dir_all(dir) {
157 return e.to_string();
158 }
159 }
160 if let Err(e) = write_json(p, &data) {
161 return e.to_string();
162 }
163 activity::log(&format!("SAVE {what} → {path}"));
164 format!("Saved {path}")
165}
166
167pub fn project_json_path() -> PathBuf {
170 let root = workspace_root();
171 if let Some(slug) = active_show().filter(|s| !s.is_empty()) {
172 let candidate = root.join("configs").join(slug).join("project.json");
173 if candidate.exists() {
174 return candidate;
175 }
176 }
177 root.join("project.json")
178}
179
180pub fn load_project_json() -> (String, PathBuf) {
182 let path = project_json_path();
183 match fs::read_to_string(&path) {
184 Ok(s) => (s, path),
185 Err(_) => ("{\n \"show\": \"\",\n \"season\": 1\n}".into(), path),
186 }
187}
188
189pub fn save_project_json(text: &str) -> String {
191 let data: Value = match serde_json::from_str(text) {
192 Ok(d) => d,
193 Err(e) => return format!("Invalid JSON — not saved: {e}"),
194 };
195 let path = project_json_path();
196 if let Err(e) = write_json(&path, &data) {
197 return e.to_string();
198 }
199 activity::log(&format!("SAVE project.json → {}", path.display()));
200 format!("Saved {}", path.display())
201}
202
203pub fn list_available_shows() -> Vec<String> {
205 let configs = workspace_root().join("configs");
206 let mut names: Vec<String> = fs::read_dir(&configs)
207 .map(|rd| {
208 rd.filter_map(Result::ok)
209 .map(|e| e.file_name().to_string_lossy().into_owned())
210 .collect()
211 })
212 .unwrap_or_default();
213 names.sort();
214 names
215 .into_iter()
216 .filter_map(|name| {
217 let pj = configs.join(&name).join("project.json");
218 if !configs.join(&name).is_dir() || !pj.exists() {
219 return None;
220 }
221 let show = fs::read_to_string(&pj)
222 .ok()
223 .and_then(|s| serde_json::from_str::<Value>(&s).ok())
224 .map(|d| match d.get("show") {
225 Some(Value::String(s)) => s.clone(),
226 _ => name.clone(),
227 });
228 Some(show.unwrap_or(name))
229 })
230 .collect()
231}
232
233pub fn active_show_name(shows: &[String]) -> Option<String> {
235 let active = active_show()?;
236 shows.iter().find(|n| show_slug(n) == active).cloned()
237}