Skip to main content

xil/cmd/
use_cmd.rs

1//! `xil use` — set or display the active show context. Port of `xil_use.py`.
2
3use 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 name or slug to activate (omit to list available shows)
25    show: Vec<String>,
26}
27
28/// `[(slug, show_name)]` for every `configs/<slug>/project.json`, in
29/// directory-name order (Python: `sorted(configs_dir.iterdir())`).
30pub 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) = &current {
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
111/// The clap definition behind `--help`, for man pages.
112pub 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}