Skip to main content

llman/
self_command.rs

1use crate::cli::Cli;
2use crate::config_schema::{
3    ApplyResult, GLOBAL_SCHEMA_URL, PROJECT_SCHEMA_URL, SchemaPaths, apply_schema_header,
4    global_config_path, project_config_path, schema_paths, write_schema_files,
5};
6use crate::fs_utils::atomic_write_with_mode;
7use crate::managed_block::find_marker_index;
8use crate::schema_utils::format_schema_errors;
9use anyhow::{Result, anyhow};
10use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::generate;
12use inquire::Confirm;
13use jsonschema::validator_for;
14use serde_json::Value;
15use std::env;
16use std::fs;
17use std::io::{self, IsTerminal};
18use std::path::{Path, PathBuf};
19
20#[derive(Parser)]
21pub struct SelfArgs {
22    #[command(subcommand)]
23    pub command: SelfCommands,
24}
25
26#[derive(Subcommand)]
27pub enum SelfCommands {
28    /// Manage llman schemas and headers
29    Schema(SchemaArgs),
30    /// Generate or install shell completions
31    Completion(CompletionArgs),
32}
33
34#[derive(Parser)]
35pub struct SchemaArgs {
36    #[command(subcommand)]
37    pub command: SchemaCommands,
38}
39
40#[derive(Subcommand)]
41pub enum SchemaCommands {
42    /// Generate JSON schema files
43    Generate,
44    /// Apply YAML LSP schema headers to config files
45    Apply,
46    /// Validate schema files against sample configs
47    Check,
48}
49
50#[derive(Parser)]
51pub struct CompletionArgs {
52    /// Target shell for completion generation
53    #[arg(long, value_enum)]
54    pub shell: CompletionShell,
55    /// Install completion block into shell rc/profile
56    #[arg(long)]
57    pub install: bool,
58    /// Skip confirmation prompt (only applies to --install)
59    #[arg(long, short = 'y')]
60    pub yes: bool,
61}
62
63#[derive(ValueEnum, Debug, Clone, Copy)]
64pub enum CompletionShell {
65    #[value(name = "bash")]
66    Bash,
67    #[value(name = "zsh")]
68    Zsh,
69    #[value(name = "fish")]
70    Fish,
71    #[value(name = "powershell")]
72    PowerShell,
73    #[value(name = "elvish")]
74    Elvish,
75}
76
77impl CompletionShell {
78    fn as_clap_shell(self) -> clap_complete::Shell {
79        match self {
80            Self::Bash => clap_complete::Shell::Bash,
81            Self::Zsh => clap_complete::Shell::Zsh,
82            Self::Fish => clap_complete::Shell::Fish,
83            Self::PowerShell => clap_complete::Shell::PowerShell,
84            Self::Elvish => clap_complete::Shell::Elvish,
85        }
86    }
87}
88
89pub fn run(args: &SelfArgs) -> Result<()> {
90    match &args.command {
91        SelfCommands::Schema(schema) => run_schema(schema),
92        SelfCommands::Completion(completion) => run_completion(completion),
93    }
94}
95
96fn run_schema(args: &SchemaArgs) -> Result<()> {
97    match args.command {
98        SchemaCommands::Generate => run_generate(),
99        SchemaCommands::Apply => run_apply(),
100        SchemaCommands::Check => run_check(),
101    }
102}
103
104fn run_completion(args: &CompletionArgs) -> Result<()> {
105    if args.install {
106        install_completion(args.shell, args.yes)
107    } else {
108        generate_completion(args.shell)
109    }
110}
111
112fn generate_completion(shell: CompletionShell) -> Result<()> {
113    let mut command = Cli::command();
114    let name = command.get_name().to_string();
115    let mut stdout = io::stdout();
116    generate(shell.as_clap_shell(), &mut command, name, &mut stdout);
117    Ok(())
118}
119
120fn install_completion(shell: CompletionShell, yes: bool) -> Result<()> {
121    install_completion_with(shell, yes, confirm_install)
122}
123
124fn install_completion_with_profile_path<F>(
125    shell: CompletionShell,
126    yes: bool,
127    profile_path: &Path,
128    confirm: F,
129) -> Result<()>
130where
131    F: Fn(&Path, bool) -> Result<bool>,
132{
133    if !confirm(profile_path, yes)? {
134        println!("{}", t!("messages.operation_cancelled"));
135        return Ok(());
136    }
137    let snippet = completion_snippet(shell);
138    update_completion_block(profile_path, snippet)?;
139    println!("{}", completion_block(shell));
140    Ok(())
141}
142
143fn install_completion_with<F>(shell: CompletionShell, yes: bool, confirm: F) -> Result<()>
144where
145    F: Fn(&Path, bool) -> Result<bool>,
146{
147    let profile_path = shell_profile_path(shell)?;
148    install_completion_with_profile_path(shell, yes, &profile_path, confirm)
149}
150
151fn confirm_install(path: &Path, yes: bool) -> Result<bool> {
152    confirm_install_with(path, yes, is_interactive_terminal, |prompt, help| {
153        Confirm::new(prompt)
154            .with_default(false)
155            .with_help_message(help)
156            .prompt()
157            .map_err(|e| anyhow!(t!("errors.inquire_error", error = e)))
158    })
159}
160
161fn confirm_install_with<I, P>(path: &Path, yes: bool, is_interactive: I, prompt: P) -> Result<bool>
162where
163    I: FnOnce() -> bool,
164    P: FnOnce(&str, &str) -> Result<bool>,
165{
166    if yes {
167        return Ok(true);
168    }
169    if !is_interactive() {
170        return Err(anyhow!(t!(
171            "self.completion.non_interactive",
172            path = path.display()
173        )));
174    }
175    let prompt_text = t!("self.completion.install_prompt", path = path.display());
176    let help = t!("self.completion.install_help");
177    prompt(&prompt_text, &help)
178}
179
180fn is_interactive_terminal() -> bool {
181    io::stdin().is_terminal() && io::stdout().is_terminal()
182}
183
184fn completion_snippet(shell: CompletionShell) -> &'static str {
185    match shell {
186        CompletionShell::Bash => "source <(llman self completion --shell bash)",
187        CompletionShell::Zsh => "source <(llman self completion --shell zsh)",
188        CompletionShell::Fish => "llman self completion --shell fish | source",
189        CompletionShell::PowerShell => {
190            "llman self completion --shell powershell | Out-String | Invoke-Expression"
191        }
192        CompletionShell::Elvish => "eval (llman self completion --shell elvish)",
193    }
194}
195
196fn completion_block(shell: CompletionShell) -> String {
197    format!(
198        "{start}\n{body}\n{end}",
199        start = COMPLETION_MARKER_START,
200        body = completion_snippet(shell),
201        end = COMPLETION_MARKER_END
202    )
203}
204
205fn shell_profile_path(shell: CompletionShell) -> Result<PathBuf> {
206    let home = crate::config::home_dir()?;
207    match shell {
208        CompletionShell::Bash => Ok(bash_profile_path(&home)),
209        CompletionShell::Zsh => Ok(home.join(".zshrc")),
210        CompletionShell::Fish => Ok(home.join(".config/fish/config.fish")),
211        CompletionShell::PowerShell => match env::var("PROFILE") {
212            Ok(profile) if !profile.trim().is_empty() => {
213                resolve_powershell_profile_under_home(&home, &profile)
214            }
215            _ => Ok(home.join(".config/powershell/Microsoft.PowerShell_profile.ps1")),
216        },
217        CompletionShell::Elvish => Ok(home.join(".elvish/rc.elv")),
218    }
219}
220
221fn resolve_powershell_profile_under_home(home: &Path, profile: &str) -> Result<PathBuf> {
222    let absolute = std::path::absolute(profile.trim())
223        .map_err(|e| anyhow!(t!("self.completion.read_failed", path = profile, error = e)))?;
224    let resolved = absolute.canonicalize().unwrap_or_else(|_| absolute.clone());
225    let home_resolved = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
226    if resolved == home_resolved || resolved.starts_with(&home_resolved) {
227        return Ok(resolved);
228    }
229    Err(anyhow!(t!(
230        "self.completion.profile_outside_home",
231        path = resolved.display()
232    )))
233}
234
235fn bash_profile_path(home: &Path) -> PathBuf {
236    let bashrc = home.join(".bashrc");
237    if bashrc.exists() {
238        return bashrc;
239    }
240    let bash_profile = home.join(".bash_profile");
241    if bash_profile.exists() {
242        return bash_profile;
243    }
244    let profile = home.join(".profile");
245    if profile.exists() {
246        return profile;
247    }
248    bashrc
249}
250
251const COMPLETION_MARKER_START: &str = "# >>> llman completion >>>";
252const COMPLETION_MARKER_END: &str = "# <<< llman completion <<<";
253
254fn update_completion_block(path: &Path, body: &str) -> Result<()> {
255    let mut content = if path.exists() {
256        fs::read_to_string(path).map_err(|e| {
257            anyhow!(t!(
258                "self.completion.read_failed",
259                path = path.display(),
260                error = e
261            ))
262        })?
263    } else {
264        String::new()
265    };
266
267    if content.is_empty() {
268        content = format!(
269            "{start}\n{body}\n{end}\n",
270            start = COMPLETION_MARKER_START,
271            body = body,
272            end = COMPLETION_MARKER_END
273        );
274    } else {
275        let start_index = find_marker_index(&content, COMPLETION_MARKER_START, 0);
276        let end_index = start_index
277            .and_then(|start| {
278                find_marker_index(
279                    &content,
280                    COMPLETION_MARKER_END,
281                    start + COMPLETION_MARKER_START.len(),
282                )
283            })
284            .or_else(|| find_marker_index(&content, COMPLETION_MARKER_END, 0));
285
286        match (start_index, end_index) {
287            (Some(start), Some(end)) => {
288                if end < start {
289                    return Err(anyhow!(t!(
290                        "self.completion.invalid_marker",
291                        path = path.display()
292                    )));
293                }
294                let before = &content[..start];
295                let after = &content[end + COMPLETION_MARKER_END.len()..];
296                content = format!(
297                    "{before}{start_marker}\n{body}\n{end_marker}{after}",
298                    start_marker = COMPLETION_MARKER_START,
299                    end_marker = COMPLETION_MARKER_END
300                );
301            }
302            (None, None) => {
303                if !content.ends_with('\n') {
304                    content.push('\n');
305                }
306                content.push_str(COMPLETION_MARKER_START);
307                content.push('\n');
308                content.push_str(body);
309                content.push('\n');
310                content.push_str(COMPLETION_MARKER_END);
311                content.push('\n');
312            }
313            _ => {
314                return Err(anyhow!(t!(
315                    "self.completion.invalid_marker",
316                    path = path.display()
317                )));
318            }
319        }
320    }
321
322    if let Some(parent) = path.parent()
323        && !parent.as_os_str().is_empty()
324    {
325        fs::create_dir_all(parent)?;
326    }
327    atomic_write_with_mode(path, content.as_bytes(), None).map_err(|e| {
328        anyhow!(t!(
329            "self.completion.write_failed",
330            path = path.display(),
331            error = e
332        ))
333    })?;
334    Ok(())
335}
336
337fn run_generate() -> Result<()> {
338    println!("{}", t!("self.schema.generate_start"));
339    let paths = write_schema_files()?;
340    print_written(&paths)?;
341    Ok(())
342}
343
344fn run_apply() -> Result<()> {
345    println!("{}", t!("self.schema.apply_start"));
346    let global_path = global_config_path()?;
347    let project_path = project_config_path()?;
348
349    apply_and_report(&global_path, GLOBAL_SCHEMA_URL)?;
350    apply_and_report(&project_path, PROJECT_SCHEMA_URL)?;
351    Ok(())
352}
353
354fn run_check() -> Result<()> {
355    let paths = schema_paths();
356    let global_schema = load_schema(&paths.global)?;
357    let project_schema = load_schema(&paths.project)?;
358
359    let global_path = global_config_path()?;
360    let project_path = project_config_path()?;
361    run_check_with_paths(&global_schema, &project_schema, &global_path, &project_path)
362}
363
364fn run_check_with_paths(
365    global_schema: &Value,
366    project_schema: &Value,
367    global_config_path: &Path,
368    project_config_path: &Path,
369) -> Result<()> {
370    println!("{}", t!("self.schema.check_start"));
371    fn sample_from_yaml_or_default<F>(path: &Path, default: F) -> Result<Value>
372    where
373        F: FnOnce() -> Result<Value>,
374    {
375        if !path.exists() {
376            return default();
377        }
378
379        let content = fs::read_to_string(path).map_err(|e| {
380            anyhow!(t!(
381                "self.schema.read_failed",
382                path = path.display(),
383                error = e
384            ))
385        })?;
386        let yaml: serde_json::Value = serde_saphyr::from_str(&content).map_err(|e| {
387            anyhow!(t!(
388                "self.schema.yaml_parse_failed",
389                path = path.display(),
390                error = e
391            ))
392        })?;
393        Ok(yaml)
394    }
395
396    validate_schema(
397        "llman-config",
398        global_schema,
399        sample_from_yaml_or_default(global_config_path, || {
400            serde_json::to_value(crate::config_schema::GlobalConfig::default()).map_err(Into::into)
401        })?,
402    )?;
403    validate_schema(
404        "llman-project-config",
405        project_schema,
406        sample_from_yaml_or_default(project_config_path, || {
407            serde_json::to_value(crate::config_schema::ProjectConfig::default()).map_err(Into::into)
408        })?,
409    )?;
410
411    println!("{}", t!("self.schema.check_ok"));
412    Ok(())
413}
414
415fn print_written(paths: &SchemaPaths) -> Result<()> {
416    println!(
417        "{}",
418        t!(
419            "self.schema.generate_written",
420            path = paths.global.display()
421        )
422    );
423    println!(
424        "{}",
425        t!(
426            "self.schema.generate_written",
427            path = paths.project.display()
428        )
429    );
430    Ok(())
431}
432
433fn apply_and_report(path: &std::path::Path, schema_url: &str) -> Result<()> {
434    match apply_schema_header(path, schema_url)? {
435        ApplyResult::Updated => {
436            println!("{}", t!("self.schema.apply_updated", path = path.display()))
437        }
438        ApplyResult::Unchanged => println!(
439            "{}",
440            t!("self.schema.apply_unchanged", path = path.display())
441        ),
442        ApplyResult::Missing => {
443            println!("{}", t!("self.schema.apply_skipped", path = path.display()))
444        }
445    }
446    Ok(())
447}
448
449fn load_schema(path: &std::path::Path) -> Result<Value> {
450    if !path.exists() {
451        return Err(anyhow!(t!(
452            "self.schema.check_missing",
453            path = path.display()
454        )));
455    }
456    let content = fs::read_to_string(path).map_err(|e| {
457        anyhow!(t!(
458            "self.schema.read_failed",
459            path = path.display(),
460            error = e
461        ))
462    })?;
463    serde_json::from_str(&content).map_err(|e| {
464        anyhow!(t!(
465            "self.schema.check_invalid",
466            path = path.display(),
467            error = e
468        ))
469    })
470}
471
472fn validate_schema(name: &str, schema: &Value, instance: Value) -> Result<()> {
473    let validator = validator_for(schema)
474        .map_err(|e| anyhow!(t!("self.schema.check_invalid", path = name, error = e)))?;
475    if !validator.is_valid(&instance) {
476        let first = format_schema_errors(validator.iter_errors(&instance).map(|e| e.to_string()));
477        return Err(anyhow!(t!(
478            "self.schema.check_failed",
479            name = name,
480            error = first
481        )));
482    }
483    Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use tempfile::TempDir;
490
491    #[test]
492    fn schema_check_uses_real_yaml_when_present() {
493        let temp = TempDir::new().expect("temp dir");
494
495        crate::config_schema::ensure_global_sample_config(temp.path()).expect("sample config");
496        let config_path = temp.path().join("config.yaml");
497        let content = fs::read_to_string(&config_path).expect("read");
498        let mut yaml: serde_json::Value = serde_saphyr::from_str(&content).expect("parse yaml");
499
500        // Make the sample config schema-invalid (version should be a string).
501        if let serde_json::Value::Object(map) = &mut yaml {
502            map.insert("version".to_string(), serde_json::Value::Bool(true));
503        } else {
504            panic!("expected mapping");
505        }
506
507        let mutated = serde_saphyr::to_string(&yaml).expect("serialize");
508        fs::write(&config_path, mutated).expect("write");
509
510        let paths = schema_paths();
511        let global_schema = load_schema(&paths.global).expect("load global schema");
512        let project_schema = load_schema(&paths.project).expect("load project schema");
513
514        let missing = temp.path().join("missing.yaml");
515        let err = run_check_with_paths(&global_schema, &project_schema, &config_path, &missing)
516            .expect_err("schema check should fail");
517        assert!(err.to_string().contains("Schema validation failed"));
518    }
519
520    #[test]
521    fn schema_check_fails_on_invalid_yaml_when_file_exists() {
522        let temp = TempDir::new().expect("temp dir");
523
524        let config_path = temp.path().join("config.yaml");
525        fs::write(&config_path, "version: [\n").expect("write invalid yaml");
526
527        let paths = schema_paths();
528        let global_schema = load_schema(&paths.global).expect("load global schema");
529        let project_schema = load_schema(&paths.project).expect("load project schema");
530
531        let missing = temp.path().join("missing.yaml");
532        let err = run_check_with_paths(&global_schema, &project_schema, &config_path, &missing)
533            .expect_err("schema check should fail");
534        assert!(err.to_string().contains("Failed to parse YAML"));
535    }
536
537    #[test]
538    fn completion_install_yes_allows_non_interactive_write() {
539        let temp_home = TempDir::new().expect("temp home");
540        let profile_path = temp_home.path().join(".bashrc");
541        install_completion_with_profile_path(
542            CompletionShell::Bash,
543            true,
544            &profile_path,
545            |path, yes| {
546                confirm_install_with(
547                    path,
548                    yes,
549                    || false,
550                    |_prompt, _help| panic!("interactive prompt should not run during tests"),
551                )
552            },
553        )
554        .expect("install should succeed");
555
556        let content = fs::read_to_string(&profile_path).expect("read profile");
557        assert!(content.contains(COMPLETION_MARKER_START));
558        assert!(content.contains(COMPLETION_MARKER_END));
559        assert!(content.contains("llman self completion --shell bash"));
560    }
561
562    #[test]
563    fn completion_install_requires_yes_in_non_interactive() {
564        let temp_home = TempDir::new().expect("temp home");
565        let profile_path = temp_home.path().join(".bashrc");
566        fs::write(&profile_path, "original\n").expect("write profile");
567
568        // Keep tests deterministic: never trigger real `inquire` interaction.
569        let err = install_completion_with_profile_path(
570            CompletionShell::Bash,
571            false,
572            &profile_path,
573            |path, yes| {
574                confirm_install_with(
575                    path,
576                    yes,
577                    || false,
578                    |_prompt, _help| panic!("interactive prompt should not run during tests"),
579                )
580            },
581        )
582        .expect_err("should error");
583        assert!(err.to_string().contains("--yes"));
584
585        let content = fs::read_to_string(&profile_path).expect("read profile");
586        assert_eq!(content, "original\n");
587    }
588
589    #[test]
590    fn confirm_install_non_interactive_skips_prompt() {
591        let path = Path::new("/tmp/fake-profile");
592        let mut prompted = false;
593
594        let err = confirm_install_with(
595            path,
596            false,
597            || false,
598            |_prompt, _help| {
599                prompted = true;
600                Ok(false)
601            },
602        )
603        .expect_err("should error");
604
605        assert!(err.to_string().contains("--yes"));
606        assert!(!prompted, "prompt callback should not be called");
607    }
608
609    #[test]
610    fn powershell_profile_outside_home_is_rejected() {
611        let temp_home = TempDir::new().expect("temp home");
612        let outside = TempDir::new().expect("outside");
613        let evil = outside.path().join("evil.ps1");
614        fs::write(&evil, "existing\n").expect("seed evil");
615
616        let mut proc = crate::test_utils::TestProcess::new();
617        proc.set_var("HOME", temp_home.path());
618        proc.set_var("PROFILE", &evil);
619
620        let err = shell_profile_path(CompletionShell::PowerShell).expect_err("reject");
621        assert!(
622            err.to_string().contains("outside the user home"),
623            "unexpected error: {err}"
624        );
625        let content = fs::read_to_string(&evil).expect("read evil");
626        assert_eq!(content, "existing\n");
627    }
628
629    #[test]
630    fn powershell_profile_under_home_is_accepted() {
631        let temp_home = TempDir::new().expect("temp home");
632        let profile = temp_home
633            .path()
634            .join(".config/powershell/Microsoft.PowerShell_profile.ps1");
635        fs::create_dir_all(profile.parent().unwrap()).expect("mkdir");
636        fs::write(&profile, "").expect("touch profile");
637
638        let mut proc = crate::test_utils::TestProcess::new();
639        proc.set_var("HOME", temp_home.path());
640        proc.set_var("PROFILE", &profile);
641
642        let resolved = shell_profile_path(CompletionShell::PowerShell).expect("accept");
643        assert_eq!(
644            resolved.canonicalize().unwrap(),
645            profile.canonicalize().unwrap()
646        );
647    }
648
649    #[test]
650    fn powershell_default_profile_when_unset() {
651        let temp_home = TempDir::new().expect("temp home");
652        let mut proc = crate::test_utils::TestProcess::new();
653        proc.set_var("HOME", temp_home.path());
654        proc.remove_var("PROFILE");
655
656        let resolved = shell_profile_path(CompletionShell::PowerShell).expect("default");
657        assert_eq!(
658            resolved,
659            temp_home
660                .path()
661                .join(".config/powershell/Microsoft.PowerShell_profile.ps1")
662        );
663    }
664}