use std::{
fs,
path::{Path, PathBuf},
};
use inquire::{Confirm, error::InquireError};
use similar::{ChangeTag, TextDiff};
use crate::{
catalog::Catalog,
commands::{ColorChoice, init},
config::Config,
diagnostics::Diagnostics,
error::{Error, Result},
palette::{fmt_label, fmt_skill_name},
skill::{SKILL_FILE_NAME, SkillTemplate, render_template},
status::normalize_line_endings,
tool::{Tool, ToolFilter},
};
#[allow(clippy::too_many_arguments)]
pub async fn run(
color: ColorChoice,
verbose: bool,
skills: Vec<String>,
all: bool,
tool_filter: ToolFilter,
dry_run: bool,
force: bool,
yes: bool,
) -> Result<()> {
init::ensure().await?;
let mut diagnostics = Diagnostics::new(verbose);
let config = Config::load()?;
let catalog = Catalog::load(&config, &mut diagnostics);
let use_color = color.enabled();
let tools = tool_filter.to_tools();
let skill_names: Vec<String> = if all {
catalog.sources.keys().cloned().collect()
} else if skills.is_empty() {
let out_of_sync = find_out_of_sync_skills(&catalog, &tools, &mut diagnostics);
if out_of_sync.is_empty() {
println!("All skills are in sync.");
return Ok(());
}
if !force && !dry_run {
println!("Skills needing push:");
for name in &out_of_sync {
println!(" {}", fmt_skill_name(name, use_color));
}
println!();
let prompt = format!("Push {} skill(s)?", out_of_sync.len());
if !confirm(&prompt)? {
println!("Aborted.");
return Ok(());
}
println!();
}
out_of_sync
} else {
for name in &skills {
if !catalog.sources.contains_key(name) {
return Err(Error::SkillNotFound { name: name.clone() });
}
}
skills
};
if skill_names.is_empty() {
println!("No skills to push.");
return Ok(());
}
let mut skill_names = skill_names;
skill_names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
let total = skill_names.len();
let mut pushed_count = 0;
let mut skipped_count = 0;
for name in &skill_names {
let template = catalog.sources.get(name).unwrap();
let results = push_skill(&catalog, template, &tools, dry_run, force, yes, use_color, &mut diagnostics)?;
let any_pushed = results.iter().any(|r| r.marker == '+' || r.marker == '~');
let any_skipped = results.iter().any(|r| r.marker == '!');
if any_pushed {
pushed_count += 1;
}
if any_skipped {
skipped_count += 1;
}
println!("{}", fmt_skill_name(name, use_color));
for result in results {
println!(
" {:<6}: {} ({})",
result.tool_label, result.marker, result.summary
);
}
}
println!();
if dry_run {
println!(
"{} {} skill(s) would be pushed.",
fmt_label("Dry run:", use_color),
total
);
} else {
println!(
"{} {} pushed, {} skipped.",
fmt_label("Done:", use_color),
pushed_count,
skipped_count
);
}
diagnostics.print_skipped_summary();
diagnostics.print_warning_summary();
Ok(())
}
fn find_out_of_sync_skills(
catalog: &Catalog,
tools: &[Tool],
diagnostics: &mut Diagnostics,
) -> Vec<String> {
let mut out_of_sync = Vec::new();
for (name, source) in &catalog.sources {
for &tool in tools {
let tool_map = catalog.tools.get(&tool);
let tool_skill = tool_map.and_then(|skills| skills.get(name));
let rendered = match render_template(&source.contents, tool) {
Ok(rendered) => rendered,
Err(error) => {
diagnostics.warn_skipped(&source.skill_path, error);
continue;
}
};
match tool_skill {
None => {
out_of_sync.push(name.clone());
break;
}
Some(installed) => {
if normalize_line_endings(&rendered)
!= normalize_line_endings(&installed.contents)
{
out_of_sync.push(name.clone());
break;
}
}
}
}
}
out_of_sync.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
out_of_sync
}
#[allow(clippy::too_many_arguments)]
fn push_skill(
catalog: &Catalog,
skill: &SkillTemplate,
tools: &[Tool],
dry_run: bool,
force: bool,
yes: bool,
use_color: bool,
diagnostics: &mut Diagnostics,
) -> Result<Vec<PushLine>> {
let mut results = Vec::new();
for &tool in tools {
let tool_dir = tool.skills_dir()?;
let rendered = match render_template(&skill.contents, tool) {
Ok(rendered) => rendered,
Err(error) => {
diagnostics.warn_skipped(&skill.skill_path, error);
results.push(PushLine {
tool_label: tool.id().to_string(),
marker: '!',
summary: "skipped".to_string(),
});
continue;
}
};
let tool_map = catalog.tools.get(&tool);
let tool_skill = tool_map.and_then(|skills| skills.get(&skill.name));
let existing = tool_skill.map(|installed| installed.contents.clone());
let status = match &existing {
None => PushStatus::New,
Some(contents) => {
if normalize_line_endings(&rendered) == normalize_line_endings(contents) {
PushStatus::Unchanged
} else {
PushStatus::Modified
}
}
};
let request = PushRequest {
skill,
tool,
tool_dir: &tool_dir,
rendered: &rendered,
existing: existing.as_deref(),
status,
};
let result = apply_push(&request, dry_run, force, yes, use_color)?;
results.push(PushLine {
tool_label: tool.id().to_string(),
marker: result.marker,
summary: result.summary,
});
}
Ok(results)
}
struct PushRequest<'a> {
skill: &'a SkillTemplate,
tool: Tool,
tool_dir: &'a PathBuf,
rendered: &'a str,
existing: Option<&'a str>,
status: PushStatus,
}
#[derive(Debug, Clone, Copy)]
enum PushStatus {
New,
Unchanged,
Modified,
}
struct PushLine {
tool_label: String,
marker: char,
summary: String,
}
struct PushResult {
marker: char,
summary: String,
}
fn apply_push(
request: &PushRequest<'_>,
dry_run: bool,
force: bool,
yes: bool,
use_color: bool,
) -> Result<PushResult> {
match request.status {
PushStatus::Unchanged => Ok(PushResult {
marker: '=',
summary: "unchanged".to_string(),
}),
PushStatus::New => {
if !dry_run {
write_tool_skill(request.tool_dir, &request.skill.name, request.rendered)?;
}
Ok(PushResult {
marker: '+',
summary: "new".to_string(),
})
}
PushStatus::Modified => {
if !dry_run {
let skip_prompt = force && yes;
if !skip_prompt {
if force {
if let Some(existing) = request.existing {
println!();
println!(
"Diff for '{}' in {}:",
request.skill.name,
request.tool.display_name()
);
print_diff(existing, request.rendered, use_color);
}
}
let prompt = format!(
"Overwrite modified skill '{}' in {}?",
request.skill.name,
request.tool.display_name()
);
let confirmed = confirm(&prompt)?;
if !confirmed {
return Ok(PushResult {
marker: '!',
summary: "skipped".to_string(),
});
}
}
write_tool_skill(request.tool_dir, &request.skill.name, request.rendered)?;
}
Ok(PushResult {
marker: '~',
summary: "pushed".to_string(),
})
}
}
}
fn print_diff(old: &str, new: &str, use_color: bool) {
use owo_colors::OwoColorize;
let diff = TextDiff::from_lines(old, new);
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
if use_color {
match change.tag() {
ChangeTag::Delete => print!("{}", format!("{}{}", sign, change).red()),
ChangeTag::Insert => print!("{}", format!("{}{}", sign, change).green()),
ChangeTag::Equal => print!(" {}", change),
}
} else {
print!("{}{}", sign, change);
}
}
println!();
}
fn confirm(message: &str) -> Result<bool> {
match Confirm::new(message).with_default(false).prompt() {
Ok(value) => Ok(value),
Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
Err(Error::PromptCanceled)
}
Err(error) => Err(Error::PromptFailed {
message: error.to_string(),
}),
}
}
fn write_tool_skill(tool_dir: &Path, name: &str, rendered: &str) -> Result<()> {
let skill_dir = tool_dir.join(name);
fs::create_dir_all(&skill_dir).map_err(|error| Error::SkillWrite {
path: skill_dir.clone(),
source: error,
})?;
let skill_path = skill_dir.join(SKILL_FILE_NAME);
fs::write(&skill_path, rendered).map_err(|error| Error::SkillWrite {
path: skill_path,
source: error,
})?;
Ok(())
}