1use std::ffi::OsString;
4use std::fs;
5use std::path::Path;
6
7use clap::Parser;
8use xil_core::log;
9use xil_core::workspace::{active_show, python_str, set_active_show, show_slug, workspace_root};
10
11#[derive(Parser)]
12#[command(
13 name = "xil-use",
14 about = "Set or display the active show context. Without arguments, lists all \
15 shows discovered in the workspace (each configs/<slug>/project.json) \
16 and marks the currently active show with an asterisk. With a show \
17 name or slug, switches the active show; subsequent commands that \
18 auto-detect the show (parse, produce, status, ...) use it until \
19 changed again.",
20 after_help = "The active show is stored in <workspace>/.active_show. A multi-word \
21 show name may be given unquoted; all arguments are joined with spaces."
22)]
23struct Args {
24 show: Vec<String>,
26}
27
28pub fn available_shows(root: &Path) -> Vec<(String, String)> {
31 let configs = root.join("configs");
32 let Ok(rd) = fs::read_dir(&configs) else {
33 return Vec::new();
34 };
35 let mut entries: Vec<_> = rd.filter_map(Result::ok).map(|e| e.path()).collect();
36 entries.sort();
37 let mut out = Vec::new();
38 for entry in entries {
39 let pj = entry.join("project.json");
40 if !(entry.is_dir() && pj.exists()) {
41 continue;
42 }
43 let slug = entry
44 .file_name()
45 .map(|n| n.to_string_lossy().into_owned())
46 .unwrap_or_default();
47 let name = fs::read_to_string(&pj)
48 .ok()
49 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
50 .and_then(|v| v.get("show").map(python_str))
51 .unwrap_or_else(|| slug.clone());
52 out.push((slug, name));
53 }
54 out
55}
56
57pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
58 log::init("use");
59 let parsed: Args = match super::parse_or_exit("xil-use", args) {
60 Ok(a) => a,
61 Err(code) => return Ok(code),
62 };
63
64 let root = workspace_root();
65 let shows = available_shows(&root);
66 let current = active_show();
67
68 if parsed.show.is_empty() {
69 if shows.is_empty() {
70 log::info("No shows found. Run 'xil init --show \"My Show\"' to create one.");
71 return Ok(0);
72 }
73 log::info("Available shows (* = active):");
74 for (slug, name) in &shows {
75 let marker = if Some(slug) == current.as_ref() {
76 "* "
77 } else {
78 " "
79 };
80 log::info(&format!(" {marker}{name} ({slug})"));
81 }
82 if let Some(cur) = ¤t {
83 if !shows.iter().any(|(s, _)| s == cur) {
84 log::warning(&format!(
85 "Active show '{cur}' has no project.json in configs/."
86 ));
87 }
88 }
89 return Ok(0);
90 }
91
92 let query = parsed.show.join(" ").trim().to_string();
93 let query_slug = show_slug(&query);
94
95 let matched = shows.iter().find(|(slug, name)| {
96 *slug == query_slug || *slug == query || show_slug(name) == query_slug
97 });
98
99 let Some((slug, name)) = matched else {
100 log::error(&format!(
101 "No show matching '{query}'. Run 'xil use' to list available shows."
102 ));
103 return Ok(1);
104 };
105
106 set_active_show(slug)?;
107 log::info(&format!("Active show: {name} ({slug})"));
108 Ok(0)
109}
110
111pub fn command() -> clap::Command {
113 <Args as clap::CommandFactory>::command()
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn make_show(root: &Path, slug: &str, name: &str) {
121 let d = root.join("configs").join(slug);
122 fs::create_dir_all(&d).unwrap();
123 fs::write(d.join("project.json"), format!(r#"{{"show": "{name}"}}"#)).unwrap();
124 }
125
126 #[test]
127 fn available_shows_sorted_by_slug_with_name() {
128 let tmp = tempfile::tempdir().unwrap();
129 make_show(tmp.path(), "the413", "THE 413");
130 make_show(tmp.path(), "nightowls", "Night Owls");
131 fs::create_dir_all(tmp.path().join("configs/noproject")).unwrap();
132 assert_eq!(
133 available_shows(tmp.path()),
134 vec![
135 ("nightowls".to_string(), "Night Owls".to_string()),
136 ("the413".to_string(), "THE 413".to_string()),
137 ]
138 );
139 }
140
141 #[test]
142 fn available_shows_falls_back_to_slug_on_bad_json() {
143 let tmp = tempfile::tempdir().unwrap();
144 let d = tmp.path().join("configs").join("broken");
145 fs::create_dir_all(&d).unwrap();
146 fs::write(d.join("project.json"), "{not json").unwrap();
147 assert_eq!(
148 available_shows(tmp.path()),
149 vec![("broken".to_string(), "broken".to_string())]
150 );
151 }
152
153 #[test]
154 fn available_shows_empty_without_configs_dir() {
155 let tmp = tempfile::tempdir().unwrap();
156 assert!(available_shows(tmp.path()).is_empty());
157 }
158}