Skip to main content

xil/cmd/
init.rs

1//! `xil init` — scaffold a new workspace. Port of `xil_init.py`.
2
3use std::ffi::OsString;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use clap::{Parser, ValueEnum};
8use serde_json::{json, Value};
9use xil_core::fsutil::abspath;
10use xil_core::log;
11use xil_core::pyjson::{dumps, Style};
12use xil_core::workspace::{set_active_show, show_slug, workspace_root};
13
14#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
15#[value(rename_all = "lower")]
16pub enum ContentType {
17    Podcast,
18    Audiobook,
19    Drama,
20    Special,
21}
22
23impl ContentType {
24    fn name(self) -> &'static str {
25        match self {
26            ContentType::Podcast => "podcast",
27            ContentType::Audiobook => "audiobook",
28            ContentType::Drama => "drama",
29            ContentType::Special => "special",
30        }
31    }
32
33    fn speakers(self) -> &'static [(&'static str, &'static str)] {
34        match self {
35            ContentType::Podcast => &[
36                ("HOST", "host"),
37                ("CO-HOST", "co_host"),
38                ("CALLER", "caller"),
39                ("NARRATOR", "narrator"),
40            ],
41            ContentType::Audiobook => &[("NARRATOR", "narrator")],
42            ContentType::Drama => &[
43                ("NARRATOR", "narrator"),
44                ("ALICE", "alice"),
45                ("BOB", "bob"),
46                ("CHARLIE", "charlie"),
47                ("DEZ", "dez"),
48            ],
49            ContentType::Special => &[("HOST", "host"), ("NARRATOR", "narrator")],
50        }
51    }
52
53    fn sample_tag(self) -> &'static str {
54        match self {
55            ContentType::Podcast | ContentType::Drama => "S01E01",
56            ContentType::Audiobook => "V01C01",
57            ContentType::Special => "SP001",
58        }
59    }
60
61    fn script(self) -> &'static str {
62        match self {
63            ContentType::Podcast => PODCAST_SCRIPT,
64            ContentType::Audiobook => AUDIOBOOK_SCRIPT,
65            ContentType::Drama => DRAMA_SCRIPT,
66            ContentType::Special => SPECIAL_SCRIPT,
67        }
68    }
69}
70
71#[derive(Parser)]
72#[command(
73    name = "xil-init",
74    about = "Scaffold a new xil-pipeline project workspace"
75)]
76struct Args {
77    /// Target directory (default: XIL_PROJECTROOT if set, else current directory)
78    directory: Option<String>,
79    /// Show name for project.json (default: "Sample Show")
80    #[arg(long, default_value = "Sample Show")]
81    show: String,
82    /// Content type: podcast (default), audiobook, drama, special
83    #[arg(long = "type", value_enum, default_value_t = ContentType::Podcast)]
84    content_type: ContentType,
85    /// Season number for project.json and the sample script header
86    #[arg(long, allow_hyphen_values = true)]
87    season: Option<i64>,
88    /// Season/arc title for project.json and the sample script header
89    #[arg(long, value_name = "TITLE")]
90    season_title: Option<String>,
91    /// Use multi-show flat layout: project.json in configs/{slug}/, per-show category subdirs (default)
92    #[arg(long, overrides_with = "no_flat")]
93    flat: bool,
94    /// Use legacy single-show layout: all dirs at workspace root
95    #[arg(long, overrides_with = "flat")]
96    no_flat: bool,
97}
98
99fn write_json(path: &Path, v: &Value) -> std::io::Result<()> {
100    fs::write(path, format!("{}\n", dumps(v, Style::INDENT2)))
101}
102
103pub fn scaffold(
104    directory: &Path,
105    show_name: &str,
106    content_type: ContentType,
107    season: Option<i64>,
108    season_title: Option<&str>,
109    flat_layout: bool,
110) -> anyhow::Result<()> {
111    fs::create_dir_all(directory)?;
112    let slug = show_slug(show_name);
113
114    let mut project = serde_json::Map::new();
115    project.insert("show".into(), Value::String(show_name.to_string()));
116    project.insert(
117        "type".into(),
118        Value::String(content_type.name().to_string()),
119    );
120    if let Some(s) = season {
121        project.insert("season".into(), Value::from(s));
122    }
123    if let Some(t) = season_title {
124        project.insert("season_title".into(), Value::String(t.to_string()));
125    }
126    if content_type == ContentType::Audiobook {
127        project.insert(
128            "tag_format".into(),
129            Value::String("V{volume:02d}C{chapter:02d}".into()),
130        );
131    }
132
133    let project_path = if flat_layout {
134        for shared in ["scripts", "SFX", "voice_samples"] {
135            fs::create_dir_all(directory.join(shared))?;
136        }
137        for slug_dir in [format!("scripts/{slug}"), format!("SFX/{slug}")] {
138            fs::create_dir_all(directory.join(slug_dir))?;
139        }
140        fs::create_dir_all(directory.join("configs").join(&slug))?;
141        for category in ["daw", "stems", "masters", "parsed", "cues", "posts"] {
142            fs::create_dir_all(directory.join(category).join(&slug))?;
143        }
144        directory.join("configs").join(&slug).join("project.json")
145    } else {
146        for sub in [
147            "scripts",
148            &format!("configs/{slug}"),
149            "parsed",
150            "stems",
151            "SFX",
152            "daw",
153            "masters",
154            "cues",
155        ] {
156            fs::create_dir_all(directory.join(sub))?;
157        }
158        directory.join("project.json")
159    };
160
161    if !project_path.exists() {
162        write_json(&project_path, &Value::Object(project))?;
163        log::info(&format!("  Created {}", project_path.display()));
164    } else {
165        log::info(&format!(
166            "  Skipped {} (already exists)",
167            project_path.display()
168        ));
169    }
170
171    let speakers_path = directory.join("configs").join(&slug).join("speakers.json");
172    if !speakers_path.exists() {
173        let speakers: Vec<Value> = content_type
174            .speakers()
175            .iter()
176            .map(|(display, key)| json!({"display": display, "key": key}))
177            .collect();
178        write_json(&speakers_path, &Value::Array(speakers))?;
179        log::info(&format!("  Created {}", speakers_path.display()));
180    } else {
181        log::info(&format!(
182            "  Skipped {} (already exists)",
183            speakers_path.display()
184        ));
185    }
186
187    let tag = content_type.sample_tag();
188    let season_part = season.map(|s| format!(" Season {s}:")).unwrap_or_default();
189    let arc_part = season_title
190        .filter(|t| !t.is_empty())
191        .map(|t| format!(" Arc: \"{t}\""))
192        .unwrap_or_default();
193    let script_path = if flat_layout {
194        directory
195            .join("scripts")
196            .join(&slug)
197            .join(format!("sample_{tag}.md"))
198    } else {
199        directory.join("scripts").join(format!("sample_{tag}.md"))
200    };
201    if !script_path.exists() {
202        let body = content_type
203            .script()
204            .replace("{show}", show_name)
205            .replace("{season_part}", &season_part)
206            .replace("{arc_part}", &arc_part);
207        fs::write(&script_path, body)?;
208        log::info(&format!("  Created {}", script_path.display()));
209    } else {
210        log::info(&format!(
211            "  Skipped {} (already exists)",
212            script_path.display()
213        ));
214    }
215
216    if flat_layout {
217        set_active_show(&slug)?;
218        log::info(&format!("  Active show set to: {slug}"));
219    }
220    Ok(())
221}
222
223/// The post-scaffold guide. `directory` is the raw CLI argument: Python
224/// formats `None` into the `cd` prefix when it was omitted, and so do we.
225fn print_getting_started(directory: Option<&str>, content_type: ContentType) {
226    let dir = directory.unwrap_or("None");
227    let cd_prefix = if dir != "." {
228        format!("cd {dir} && ")
229    } else {
230        String::new()
231    };
232    let tag = content_type.sample_tag();
233    log::info(&format!(
234        "\nGetting Started\n===============\n\n1. Install the pipeline:\n   pip install xil-pipeline\n\n\
235         2. Scan the sample script (pre-flight check):\n   {cd_prefix}xil-scan scripts/sample_{tag}.md\n\n\
236         3. Parse the script into structured JSON:\n   {cd_prefix}xil-parse scripts/sample_{tag}.md --episode {tag}\n\n\
237         4. Preview voice generation (no API key needed):\n   {cd_prefix}xil-produce --episode {tag} --dry-run\n\n\
238         5. To use your own script:\n   - Edit configs/<slug>/speakers.json with your cast\n   - Write your script in scripts/\n   \
239         - Set your ElevenLabs API key: export ELEVENLABS_API_KEY=your-key\n   - Run the pipeline stages in order (see README.md)\n"
240    ));
241}
242
243pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
244    log::init("init");
245    let a: Args = match super::parse_or_exit("xil-init", args) {
246        Ok(a) => a,
247        Err(code) => return Ok(code),
248    };
249    let flat_layout = !a.no_flat;
250    let directory: PathBuf = match &a.directory {
251        None => workspace_root(),
252        Some(d) => abspath(Path::new(d)),
253    };
254    log::info(&format!(
255        "\nScaffolding xil-pipeline workspace in: {}",
256        directory.display()
257    ));
258    log::info(&format!(
259        "Show: {}  Type: {}  Flat layout: {}\n",
260        a.show,
261        a.content_type.name(),
262        if flat_layout { "True" } else { "False" }
263    ));
264    scaffold(
265        &directory,
266        &a.show,
267        a.content_type,
268        a.season,
269        a.season_title.as_deref(),
270        flat_layout,
271    )?;
272    print_getting_started(a.directory.as_deref(), a.content_type);
273    Ok(0)
274}
275
276const PODCAST_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Pilot"{arc_part}
277
278CAST:
279* HOST — the radio host
280* CO-HOST — the co-host
281* CALLER — a mysterious caller
282
283===
284
285COLD OPEN
286
287SCENE 1: THE STUDIO
288
289[AMBIENCE: Radio station studio, low hum of equipment]
290
291HOST
292Good evening, and welcome to the show. I'm your host.
293
294CO-HOST
295And I'm your co-host. We have a packed show tonight.
296
297[SFX: Phone ringing]
298
299HOST (picking up phone)
300We have our first caller of the night. You're on the air.
301
302CALLER (distorted, nervous)
303Hi... I wasn't sure I should call, but something strange happened last night.
304
305[BEAT]
306
307HOST
308Take your time. Tell us what happened.
309
310CALLER
311I was driving home when the radio cut out. Just static. And then... a voice.
312
313[BEAT — 3 SECONDS]
314
315===
316
317ACT ONE
318
319SCENE 2: THE INTERVIEW
320
321[AMBIENCE: Same studio, quieter now]
322
323HOST
324We're going to dig into that after this break. Don't go anywhere.
325
326CO-HOST
327When we come back — the story that has our phones ringing off the hook.
328
329[MUSIC: Upbeat podcast bumper]
330
331[BEAT]
332
333===
334
335MID-EPISODE BREAK
336
337HOST
338You're listening to {show}. I'm your host, back with my co-host.
339
340===
341
342ACT TWO
343
344SCENE 3: WRAP-UP
345
346CO-HOST
347Fascinating stuff. Thank you to everyone who called in tonight.
348
349HOST
350That's all for this episode. Until next time.
351
352[MUSIC: Closing theme, fade out]
353
354===
355
356CLOSING
357
358HOST
359{show} is produced by [Your Name]. Subscribe wherever you get your podcasts.
360
361===
362
363END OF EPISODE
364"#;
365
366const AUDIOBOOK_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Chapter One"{arc_part}
367
368CAST:
369* NARRATOR — the narrator
370
371===
372
373PROLOGUE
374
375NARRATOR
376Before we begin, a note from the author.
377
378[BEAT — 2 SECONDS]
379
380NARRATOR
381The events in this story are entirely fictional. Any resemblance to actual
382persons, living or dead, is purely coincidental.
383
384[BEAT — 3 SECONDS]
385
386===
387
388CHAPTER ONE
389
390NARRATOR
391It began, as most things do, on an ordinary morning.
392
393[BEAT]
394
395NARRATOR
396The sun rose over the hills, casting long shadows across the valley below.
397Nothing about that day seemed remarkable. And yet, by nightfall, everything
398would change.
399
400[SFX: Birds chirping, distant wind]
401
402NARRATOR
403She stepped onto the porch with her coffee and looked out at the horizon.
404Something was different. She couldn't say what — only that the light seemed
405wrong, somehow. Too bright. Too still.
406
407[BEAT — 2 SECONDS]
408
409NARRATOR
410She went back inside.
411
412[BEAT — 3 SECONDS]
413
414===
415
416CHAPTER TWO
417
418NARRATOR
419Three days passed before anyone noticed she was gone.
420
421[BEAT]
422
423NARRATOR
424The neighbor across the lane saw the mail piling up, the lights going dark
425one by one. She mentioned it to her husband. He said to leave it alone.
426
427[BEAT — 2 SECONDS]
428
429NARRATOR
430She didn't leave it alone.
431
432[BEAT — 3 SECONDS]
433
434===
435
436END OF EPISODE
437"#;
438
439const DRAMA_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Pilot"{arc_part}
440
441CAST:
442* NARRATOR — the narrator
443* ALICE — protagonist
444* BOB — antagonist
445* CHARLIE — a bystander
446
447===
448
449ACT ONE
450
451SCENE 1: THE STREET
452
453[AMBIENCE: City street, distant traffic, wind]
454
455NARRATOR
456The city never sleeps. But tonight, it should have.
457
458[BEAT]
459
460ALICE (quietly)
461I shouldn't be here.
462
463BOB (behind her)
464And yet, here you are.
465
466[SFX: Footsteps on wet pavement]
467
468ALICE
469How long have you been following me?
470
471BOB (calm)
472Long enough to know you found the documents.
473
474[BEAT — 2 SECONDS]
475
476ALICE
477I don't know what you're talking about.
478
479BOB
480We both know that's not true.
481
482[SFX: Car horn, distant]
483
484CHARLIE (interrupting)
485Hey — is everything all right over there?
486
487[BEAT]
488
489BOB (forced smile)
490Just old friends catching up.
491
492===
493
494ACT TWO
495
496SCENE 2: THE ALLEY
497
498[AMBIENCE: Narrow alley, dripping water, distant music]
499
500NARRATOR
501Alice ran. She didn't look back.
502
503[SFX: Running footsteps, splashing]
504
505ALICE (breathless, to herself)
506I need to get to the bridge. I need to warn them.
507
508[BEAT — 3 SECONDS]
509
510NARRATOR
511The bridge was three blocks away. Three very long blocks.
512
513[MUSIC: Tense underscore, building]
514
515[BEAT]
516
517CHARLIE (stepping out of shadow)
518I was hoping I'd find you first.
519
520ALICE
521Charlie? What are you doing here?
522
523CHARLIE (serious)
524Keeping you alive. Come with me.
525
526[SFX: Door opening]
527
528===
529
530END OF EPISODE
531"#;
532
533const SPECIAL_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Special Presentation"{arc_part}
534
535CAST:
536* HOST — the host
537* NARRATOR — the narrator
538
539===
540
541INTRO
542
543[MUSIC: Fanfare, brief]
544
545HOST
546Welcome to this special presentation of {show}.
547
548NARRATOR
549What follows is a one-time look at something we don't often discuss.
550
551[BEAT — 2 SECONDS]
552
553===
554
555SEGMENT 1
556
557HOST
558Tonight we're exploring something that has fascinated people for generations.
559
560NARRATOR
561The question is simple. The answer, anything but.
562
563[SFX: Ambient texture, subtle]
564
565HOST
566Let's begin.
567
568[BEAT]
569
570===
571
572SEGMENT 2
573
574HOST
575Thank you for staying with us.
576
577NARRATOR
578We leave you with this thought: the most important stories are the ones
579we tell ourselves.
580
581[BEAT — 3 SECONDS]
582
583HOST
584Until next time.
585
586===
587
588OUTRO
589
590[MUSIC: Closing theme]
591
592HOST
593This has been {show}. Thank you for listening.
594
595===
596
597END OF EPISODE
598"#;
599
600/// The clap definition behind `--help`, for man pages.
601pub fn command() -> clap::Command {
602    <Args as clap::CommandFactory>::command()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn flat_scaffold_writes_project_speakers_script() {
611        let _env = crate::cmd::ENV_LOCK
612            .lock()
613            .unwrap_or_else(|e| e.into_inner());
614        let tmp = tempfile::tempdir().unwrap();
615        let ws = tmp.path().join("ws");
616        std::env::set_var("XIL_PROJECTROOT", &ws);
617        scaffold(
618            &ws,
619            "Night Owls",
620            ContentType::Audiobook,
621            Some(2),
622            Some("Arc Two"),
623            true,
624        )
625        .unwrap();
626        let pj = fs::read_to_string(ws.join("configs/nightowls/project.json")).unwrap();
627        assert_eq!(
628            pj,
629            "{\n  \"show\": \"Night Owls\",\n  \"type\": \"audiobook\",\n  \"season\": 2,\n  \"season_title\": \"Arc Two\",\n  \"tag_format\": \"V{volume:02d}C{chapter:02d}\"\n}\n"
630        );
631        let sp = fs::read_to_string(ws.join("configs/nightowls/speakers.json")).unwrap();
632        assert_eq!(
633            sp,
634            "[\n  {\n    \"display\": \"NARRATOR\",\n    \"key\": \"narrator\"\n  }\n]\n"
635        );
636        let script = fs::read_to_string(ws.join("scripts/nightowls/sample_V01C01.md")).unwrap();
637        assert!(script
638            .starts_with("Night Owls Season 2: Episode 1: \"Chapter One\" Arc: \"Arc Two\"\n"));
639        assert!(ws.join("posts/nightowls").is_dir());
640        assert_eq!(
641            fs::read_to_string(ws.join(".active_show")).unwrap(),
642            "nightowls"
643        );
644        std::env::remove_var("XIL_PROJECTROOT");
645    }
646
647    #[test]
648    fn legacy_scaffold_puts_project_at_root() {
649        let tmp = tempfile::tempdir().unwrap();
650        let ws = tmp.path().join("legacy");
651        scaffold(&ws, "Sample Show", ContentType::Podcast, None, None, false).unwrap();
652        assert!(ws.join("project.json").exists());
653        assert!(ws.join("scripts/sample_S01E01.md").exists());
654        assert!(!ws.join("posts").exists());
655    }
656}