use crate::cli::Cli;
use crate::config_schema::{
ApplyResult, GLOBAL_SCHEMA_URL, LLMANSPEC_SCHEMA_URL, PROJECT_SCHEMA_URL, SchemaPaths,
apply_schema_header, format_schema_errors, global_config_path, llmanspec_config_path,
project_config_path, schema_paths, write_schema_files,
};
use crate::fs_utils::atomic_write_with_mode;
use anyhow::{Result, anyhow};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::generate;
use inquire::Confirm;
use jsonschema::validator_for;
use serde_json::Value;
use std::env;
use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
#[derive(Parser)]
pub struct SelfArgs {
#[command(subcommand)]
pub command: SelfCommands,
}
#[derive(Subcommand)]
pub enum SelfCommands {
Schema(SchemaArgs),
Completion(CompletionArgs),
}
#[derive(Parser)]
pub struct SchemaArgs {
#[command(subcommand)]
pub command: SchemaCommands,
}
#[derive(Subcommand)]
pub enum SchemaCommands {
Generate,
Apply,
Check,
}
#[derive(Parser)]
pub struct CompletionArgs {
#[arg(long, value_enum)]
pub shell: CompletionShell,
#[arg(long)]
pub install: bool,
#[arg(long, short = 'y')]
pub yes: bool,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum CompletionShell {
#[value(name = "bash")]
Bash,
#[value(name = "zsh")]
Zsh,
#[value(name = "fish")]
Fish,
#[value(name = "powershell")]
PowerShell,
#[value(name = "elvish")]
Elvish,
}
impl CompletionShell {
fn as_clap_shell(self) -> clap_complete::Shell {
match self {
Self::Bash => clap_complete::Shell::Bash,
Self::Zsh => clap_complete::Shell::Zsh,
Self::Fish => clap_complete::Shell::Fish,
Self::PowerShell => clap_complete::Shell::PowerShell,
Self::Elvish => clap_complete::Shell::Elvish,
}
}
}
pub fn run(args: &SelfArgs) -> Result<()> {
match &args.command {
SelfCommands::Schema(schema) => run_schema(schema),
SelfCommands::Completion(completion) => run_completion(completion),
}
}
fn run_schema(args: &SchemaArgs) -> Result<()> {
match args.command {
SchemaCommands::Generate => run_generate(),
SchemaCommands::Apply => run_apply(),
SchemaCommands::Check => run_check(),
}
}
fn run_completion(args: &CompletionArgs) -> Result<()> {
if args.install {
install_completion(args.shell, args.yes)
} else {
generate_completion(args.shell)
}
}
fn generate_completion(shell: CompletionShell) -> Result<()> {
let mut command = Cli::command();
let name = command.get_name().to_string();
let mut stdout = io::stdout();
generate(shell.as_clap_shell(), &mut command, name, &mut stdout);
Ok(())
}
fn install_completion(shell: CompletionShell, yes: bool) -> Result<()> {
install_completion_with(shell, yes, confirm_install)
}
fn install_completion_with_profile_path<F>(
shell: CompletionShell,
yes: bool,
profile_path: &Path,
confirm: F,
) -> Result<()>
where
F: Fn(&Path, bool) -> Result<bool>,
{
if !confirm(profile_path, yes)? {
println!("{}", t!("messages.operation_cancelled"));
return Ok(());
}
let snippet = completion_snippet(shell);
update_completion_block(profile_path, snippet)?;
println!("{}", completion_block(shell));
Ok(())
}
fn install_completion_with<F>(shell: CompletionShell, yes: bool, confirm: F) -> Result<()>
where
F: Fn(&Path, bool) -> Result<bool>,
{
let profile_path = shell_profile_path(shell)?;
install_completion_with_profile_path(shell, yes, &profile_path, confirm)
}
fn confirm_install(path: &Path, yes: bool) -> Result<bool> {
confirm_install_with(path, yes, is_interactive_terminal, |prompt, help| {
Confirm::new(prompt)
.with_default(false)
.with_help_message(help)
.prompt()
.map_err(|e| anyhow!(t!("errors.inquire_error", error = e)))
})
}
fn confirm_install_with<I, P>(path: &Path, yes: bool, is_interactive: I, prompt: P) -> Result<bool>
where
I: FnOnce() -> bool,
P: FnOnce(&str, &str) -> Result<bool>,
{
if yes {
return Ok(true);
}
if !is_interactive() {
return Err(anyhow!(t!(
"self.completion.non_interactive",
path = path.display()
)));
}
let prompt_text = t!("self.completion.install_prompt", path = path.display());
let help = t!("self.completion.install_help");
prompt(&prompt_text, &help)
}
fn is_interactive_terminal() -> bool {
io::stdin().is_terminal() && io::stdout().is_terminal()
}
fn completion_snippet(shell: CompletionShell) -> &'static str {
match shell {
CompletionShell::Bash => "source <(llman self completion --shell bash)",
CompletionShell::Zsh => "source <(llman self completion --shell zsh)",
CompletionShell::Fish => "llman self completion --shell fish | source",
CompletionShell::PowerShell => {
"llman self completion --shell powershell | Out-String | Invoke-Expression"
}
CompletionShell::Elvish => "eval (llman self completion --shell elvish)",
}
}
fn completion_block(shell: CompletionShell) -> String {
format!(
"{start}\n{body}\n{end}",
start = COMPLETION_MARKER_START,
body = completion_snippet(shell),
end = COMPLETION_MARKER_END
)
}
fn shell_profile_path(shell: CompletionShell) -> Result<PathBuf> {
let home = crate::config::home_dir()?;
match shell {
CompletionShell::Bash => Ok(bash_profile_path(&home)),
CompletionShell::Zsh => Ok(home.join(".zshrc")),
CompletionShell::Fish => Ok(home.join(".config/fish/config.fish")),
CompletionShell::PowerShell => match env::var("PROFILE") {
Ok(profile) if !profile.trim().is_empty() => Ok(PathBuf::from(profile)),
_ => Ok(home.join(".config/powershell/Microsoft.PowerShell_profile.ps1")),
},
CompletionShell::Elvish => Ok(home.join(".elvish/rc.elv")),
}
}
fn bash_profile_path(home: &Path) -> PathBuf {
let bashrc = home.join(".bashrc");
if bashrc.exists() {
return bashrc;
}
let bash_profile = home.join(".bash_profile");
if bash_profile.exists() {
return bash_profile;
}
let profile = home.join(".profile");
if profile.exists() {
return profile;
}
bashrc
}
const COMPLETION_MARKER_START: &str = "# >>> llman completion >>>";
const COMPLETION_MARKER_END: &str = "# <<< llman completion <<<";
fn is_marker_on_own_line(content: &str, marker_index: usize, marker_len: usize) -> bool {
let bytes = content.as_bytes();
let mut left = marker_index as isize - 1;
while left >= 0 {
let ch = bytes[left as usize] as char;
if ch == '\n' {
break;
}
if ch != ' ' && ch != '\t' && ch != '\r' {
return false;
}
left -= 1;
}
let mut right = marker_index + marker_len;
while right < bytes.len() {
let ch = bytes[right] as char;
if ch == '\n' {
break;
}
if ch != ' ' && ch != '\t' && ch != '\r' {
return false;
}
right += 1;
}
true
}
fn find_marker_index(content: &str, marker: &str, from_index: usize) -> Option<usize> {
let mut search_index = from_index;
while let Some(pos) = content[search_index..].find(marker) {
let idx = search_index + pos;
if is_marker_on_own_line(content, idx, marker.len()) {
return Some(idx);
}
search_index = idx + marker.len();
if search_index >= content.len() {
break;
}
}
None
}
fn update_completion_block(path: &Path, body: &str) -> Result<()> {
let mut content = if path.exists() {
fs::read_to_string(path).map_err(|e| {
anyhow!(t!(
"self.completion.read_failed",
path = path.display(),
error = e
))
})?
} else {
String::new()
};
if content.is_empty() {
content = format!(
"{start}\n{body}\n{end}\n",
start = COMPLETION_MARKER_START,
body = body,
end = COMPLETION_MARKER_END
);
} else {
let start_index = find_marker_index(&content, COMPLETION_MARKER_START, 0);
let end_index = start_index
.and_then(|start| {
find_marker_index(
&content,
COMPLETION_MARKER_END,
start + COMPLETION_MARKER_START.len(),
)
})
.or_else(|| find_marker_index(&content, COMPLETION_MARKER_END, 0));
match (start_index, end_index) {
(Some(start), Some(end)) => {
if end < start {
return Err(anyhow!(t!(
"self.completion.invalid_marker",
path = path.display()
)));
}
let before = &content[..start];
let after = &content[end + COMPLETION_MARKER_END.len()..];
content = format!(
"{before}{start_marker}\n{body}\n{end_marker}{after}",
start_marker = COMPLETION_MARKER_START,
end_marker = COMPLETION_MARKER_END
);
}
(None, None) => {
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(COMPLETION_MARKER_START);
content.push('\n');
content.push_str(body);
content.push('\n');
content.push_str(COMPLETION_MARKER_END);
content.push('\n');
}
_ => {
return Err(anyhow!(t!(
"self.completion.invalid_marker",
path = path.display()
)));
}
}
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
atomic_write_with_mode(path, content.as_bytes(), None).map_err(|e| {
anyhow!(t!(
"self.completion.write_failed",
path = path.display(),
error = e
))
})?;
Ok(())
}
fn run_generate() -> Result<()> {
println!("{}", t!("self.schema.generate_start"));
let paths = write_schema_files()?;
print_written(&paths)?;
Ok(())
}
fn run_apply() -> Result<()> {
println!("{}", t!("self.schema.apply_start"));
let global_path = global_config_path()?;
let project_path = project_config_path()?;
let llmanspec_path = llmanspec_config_path()?;
apply_and_report(&global_path, GLOBAL_SCHEMA_URL)?;
apply_and_report(&project_path, PROJECT_SCHEMA_URL)?;
apply_and_report(&llmanspec_path, LLMANSPEC_SCHEMA_URL)?;
Ok(())
}
fn run_check() -> Result<()> {
let paths = schema_paths();
let global_schema = load_schema(&paths.global)?;
let project_schema = load_schema(&paths.project)?;
let llmanspec_schema = load_schema(&paths.llmanspec)?;
let sdd_eval_playbook_schema = load_schema(&paths.sdd_eval_playbook)?;
let global_path = global_config_path()?;
let project_path = project_config_path()?;
let llmanspec_path = llmanspec_config_path()?;
run_check_with_paths(
&global_schema,
&project_schema,
&llmanspec_schema,
&sdd_eval_playbook_schema,
&global_path,
&project_path,
&llmanspec_path,
)
}
fn run_check_with_paths(
global_schema: &Value,
project_schema: &Value,
llmanspec_schema: &Value,
sdd_eval_playbook_schema: &Value,
global_config_path: &Path,
project_config_path: &Path,
llmanspec_config_path: &Path,
) -> Result<()> {
println!("{}", t!("self.schema.check_start"));
fn sample_from_yaml_or_default<F>(path: &Path, default: F) -> Result<Value>
where
F: FnOnce() -> Result<Value>,
{
if !path.exists() {
return default();
}
let content = fs::read_to_string(path).map_err(|e| {
anyhow!(t!(
"self.schema.read_failed",
path = path.display(),
error = e
))
})?;
let yaml: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| {
anyhow!(t!(
"self.schema.yaml_parse_failed",
path = path.display(),
error = e
))
})?;
serde_json::to_value(yaml)
.map_err(|e| anyhow!(t!("errors.config_error", message = e.to_string())))
}
validate_schema(
"llman-config",
global_schema,
sample_from_yaml_or_default(global_config_path, || {
serde_json::to_value(crate::config_schema::GlobalConfig::default()).map_err(Into::into)
})?,
)?;
validate_schema(
"llman-project-config",
project_schema,
sample_from_yaml_or_default(project_config_path, || {
serde_json::to_value(crate::config_schema::ProjectConfig::default()).map_err(Into::into)
})?,
)?;
validate_schema(
"llmanspec-config",
llmanspec_schema,
sample_from_yaml_or_default(llmanspec_config_path, || {
serde_json::to_value(crate::sdd::project::config::SddConfig::default())
.map_err(Into::into)
})?,
)?;
let playbook_yaml = crate::x::sdd_eval::playbook::default_template_yaml();
let playbook_value: serde_yaml::Value = serde_yaml::from_str(playbook_yaml).map_err(|e| {
anyhow!(t!(
"self.schema.yaml_parse_failed",
path = "sdd-eval template",
error = e
))
})?;
let playbook_instance = serde_json::to_value(playbook_value)
.map_err(|e| anyhow!(t!("errors.config_error", message = e.to_string())))?;
validate_schema(
"llman-sdd-eval-playbook",
sdd_eval_playbook_schema,
playbook_instance,
)?;
println!("{}", t!("self.schema.check_ok"));
Ok(())
}
fn print_written(paths: &SchemaPaths) -> Result<()> {
println!(
"{}",
t!(
"self.schema.generate_written",
path = paths.global.display()
)
);
println!(
"{}",
t!(
"self.schema.generate_written",
path = paths.project.display()
)
);
println!(
"{}",
t!(
"self.schema.generate_written",
path = paths.llmanspec.display()
)
);
println!(
"{}",
t!(
"self.schema.generate_written",
path = paths.sdd_eval_playbook.display()
)
);
Ok(())
}
fn apply_and_report(path: &std::path::Path, schema_url: &str) -> Result<()> {
match apply_schema_header(path, schema_url)? {
ApplyResult::Updated => {
println!("{}", t!("self.schema.apply_updated", path = path.display()))
}
ApplyResult::Unchanged => println!(
"{}",
t!("self.schema.apply_unchanged", path = path.display())
),
ApplyResult::Missing => {
println!("{}", t!("self.schema.apply_skipped", path = path.display()))
}
}
Ok(())
}
fn load_schema(path: &std::path::Path) -> Result<Value> {
if !path.exists() {
return Err(anyhow!(t!(
"self.schema.check_missing",
path = path.display()
)));
}
let content = fs::read_to_string(path).map_err(|e| {
anyhow!(t!(
"self.schema.read_failed",
path = path.display(),
error = e
))
})?;
serde_json::from_str(&content).map_err(|e| {
anyhow!(t!(
"self.schema.check_invalid",
path = path.display(),
error = e
))
})
}
fn validate_schema(name: &str, schema: &Value, instance: Value) -> Result<()> {
let validator = validator_for(schema)
.map_err(|e| anyhow!(t!("self.schema.check_invalid", path = name, error = e)))?;
if !validator.is_valid(&instance) {
let first = format_schema_errors(validator.iter_errors(&instance).map(|e| e.to_string()));
return Err(anyhow!(t!(
"self.schema.check_failed",
name = name,
error = first
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn schema_check_uses_real_yaml_when_present() {
let temp = TempDir::new().expect("temp dir");
crate::config_schema::ensure_global_sample_config(temp.path()).expect("sample config");
let config_path = temp.path().join("config.yaml");
let content = fs::read_to_string(&config_path).expect("read");
let mut yaml: serde_yaml::Value = serde_yaml::from_str(&content).expect("parse yaml");
if let serde_yaml::Value::Mapping(map) = &mut yaml {
map.insert(
serde_yaml::Value::String("version".to_string()),
serde_yaml::Value::Bool(true),
);
} else {
panic!("expected mapping");
}
let mutated = serde_yaml::to_string(&yaml).expect("serialize");
fs::write(&config_path, mutated).expect("write");
let paths = schema_paths();
let global_schema = load_schema(&paths.global).expect("load global schema");
let project_schema = load_schema(&paths.project).expect("load project schema");
let llmanspec_schema = load_schema(&paths.llmanspec).expect("load llmanspec schema");
let sdd_eval_playbook_schema =
load_schema(&paths.sdd_eval_playbook).expect("load sdd-eval playbook schema");
let missing = temp.path().join("missing.yaml");
let err = run_check_with_paths(
&global_schema,
&project_schema,
&llmanspec_schema,
&sdd_eval_playbook_schema,
&config_path,
&missing,
&missing,
)
.expect_err("schema check should fail");
assert!(err.to_string().contains("Schema validation failed"));
}
#[test]
fn schema_check_fails_on_invalid_yaml_when_file_exists() {
let temp = TempDir::new().expect("temp dir");
let config_path = temp.path().join("config.yaml");
fs::write(&config_path, "version: [\n").expect("write invalid yaml");
let paths = schema_paths();
let global_schema = load_schema(&paths.global).expect("load global schema");
let project_schema = load_schema(&paths.project).expect("load project schema");
let llmanspec_schema = load_schema(&paths.llmanspec).expect("load llmanspec schema");
let sdd_eval_playbook_schema =
load_schema(&paths.sdd_eval_playbook).expect("load sdd-eval playbook schema");
let missing = temp.path().join("missing.yaml");
let err = run_check_with_paths(
&global_schema,
&project_schema,
&llmanspec_schema,
&sdd_eval_playbook_schema,
&config_path,
&missing,
&missing,
)
.expect_err("schema check should fail");
assert!(err.to_string().contains("Failed to parse YAML"));
}
#[test]
fn completion_install_yes_allows_non_interactive_write() {
let temp_home = TempDir::new().expect("temp home");
let profile_path = temp_home.path().join(".bashrc");
install_completion_with_profile_path(
CompletionShell::Bash,
true,
&profile_path,
|path, yes| {
confirm_install_with(
path,
yes,
|| false,
|_prompt, _help| panic!("interactive prompt should not run during tests"),
)
},
)
.expect("install should succeed");
let content = fs::read_to_string(&profile_path).expect("read profile");
assert!(content.contains(COMPLETION_MARKER_START));
assert!(content.contains(COMPLETION_MARKER_END));
assert!(content.contains("llman self completion --shell bash"));
}
#[test]
fn completion_install_requires_yes_in_non_interactive() {
let temp_home = TempDir::new().expect("temp home");
let profile_path = temp_home.path().join(".bashrc");
fs::write(&profile_path, "original\n").expect("write profile");
let err = install_completion_with_profile_path(
CompletionShell::Bash,
false,
&profile_path,
|path, yes| {
confirm_install_with(
path,
yes,
|| false,
|_prompt, _help| panic!("interactive prompt should not run during tests"),
)
},
)
.expect_err("should error");
assert!(err.to_string().contains("--yes"));
let content = fs::read_to_string(&profile_path).expect("read profile");
assert_eq!(content, "original\n");
}
#[test]
fn confirm_install_non_interactive_skips_prompt() {
let path = Path::new("/tmp/fake-profile");
let mut prompted = false;
let err = confirm_install_with(
path,
false,
|| false,
|_prompt, _help| {
prompted = true;
Ok(false)
},
)
.expect_err("should error");
assert!(err.to_string().contains("--yes"));
assert!(!prompted, "prompt callback should not be called");
}
}