| mod plugin; |
| mod render; |
|
|
| use render::rewrite_terms; |
| use render::slugify_name; |
| use render::yaml_string; |
| use serde_yaml::Value as YamlValue; |
| use std::collections::BTreeMap; |
| use std::fs; |
| use std::io; |
| use std::path::Path; |
| use std::path::PathBuf; |
|
|
| const COMMAND_SKILL_PREFIX: &str = "source-command"; |
| const MAX_SKILL_NAME_LEN: usize = 64; |
|
|
| pub(crate) use plugin::migrate_plugin_commands; |
|
|
| |
| #[derive(Clone, Copy)] |
| pub struct RewriteProfile { |
| doc_file_name: &'static str, |
| term_variants: &'static [&'static str], |
| case_sensitive_term_variants: &'static [&'static str], |
| } |
|
|
| impl RewriteProfile { |
| pub const fn new(doc_file_name: &'static str, term_variants: &'static [&'static str]) -> Self { |
| Self { |
| doc_file_name, |
| term_variants, |
| case_sensitive_term_variants: &[], |
| } |
| } |
|
|
| pub const fn with_case_sensitive_term_variants( |
| mut self, |
| term_variants: &'static [&'static str], |
| ) -> Self { |
| self.case_sensitive_term_variants = term_variants; |
| self |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| pub enum CommandDescriptionMode { |
| |
| RequireFrontmatter, |
| |
| UseSourceNameFallback, |
| } |
|
|
| |
| #[derive(Clone, Copy)] |
| pub struct CommandMigrationProfile { |
| rewrite_profile: RewriteProfile, |
| description_mode: CommandDescriptionMode, |
| } |
|
|
| impl CommandMigrationProfile { |
| pub const fn new( |
| rewrite_profile: RewriteProfile, |
| description_mode: CommandDescriptionMode, |
| ) -> Self { |
| Self { |
| rewrite_profile, |
| description_mode, |
| } |
| } |
| } |
|
|
| #[derive(Debug)] |
| struct ParsedCommand { |
| description: Option<String>, |
| body: String, |
| } |
|
|
| #[derive(Debug, PartialEq, Eq)] |
| struct CommandSource { |
| source_file: PathBuf, |
| name: String, |
| source_name: String, |
| } |
|
|
| #[derive(Clone, Copy)] |
| enum CommandSkillSizeLimit { |
| Unbounded, |
| MaxBytes(usize), |
| } |
|
|
| pub fn count_missing_commands_with_profile( |
| source_commands: &Path, |
| target_skills: &Path, |
| profile: CommandMigrationProfile, |
| ) -> io::Result<usize> { |
| Ok(missing_command_names_with_profile(source_commands, target_skills, profile)?.len()) |
| } |
|
|
| pub fn missing_command_names_with_profile( |
| source_commands: &Path, |
| target_skills: &Path, |
| profile: CommandMigrationProfile, |
| ) -> io::Result<Vec<String>> { |
| Ok( |
| unique_supported_command_sources(source_commands, profile.description_mode)? |
| .into_iter() |
| .filter(|source| !target_skills.join(&source.name).exists()) |
| .map(|source| source.name) |
| .collect(), |
| ) |
| } |
|
|
| pub fn import_commands_with_profile( |
| source_commands: &Path, |
| target_skills: &Path, |
| profile: CommandMigrationProfile, |
| ) -> io::Result<Vec<String>> { |
| if !source_commands.is_dir() { |
| return Ok(Vec::new()); |
| } |
| fs::create_dir_all(target_skills)?; |
| import_command_sources( |
| unique_supported_command_sources(source_commands, profile.description_mode)?, |
| target_skills, |
| profile, |
| CommandSkillSizeLimit::Unbounded, |
| ) |
| } |
|
|
| fn import_command_sources( |
| command_sources: Vec<CommandSource>, |
| target_skills: &Path, |
| profile: CommandMigrationProfile, |
| size_limit: CommandSkillSizeLimit, |
| ) -> io::Result<Vec<String>> { |
| if command_sources.is_empty() { |
| return Ok(Vec::new()); |
| } |
|
|
| let mut imported = Vec::new(); |
| for CommandSource { |
| source_file, |
| name, |
| source_name, |
| } in command_sources |
| { |
| let document = parse_command(&source_file)?; |
| let target_dir = target_skills.join(&name); |
| if target_dir.exists() { |
| continue; |
| } |
| let Some(description) = |
| command_skill_description(&document, &source_name, profile.description_mode) |
| else { |
| continue; |
| }; |
| let rendered = render_command_skill( |
| &document.body, |
| &name, |
| &description, |
| &source_name, |
| profile.rewrite_profile, |
| ); |
| if let CommandSkillSizeLimit::MaxBytes(max_bytes) = size_limit |
| && rendered.len() > max_bytes |
| { |
| continue; |
| } |
| fs::create_dir_all(&target_dir)?; |
| fs::write(target_dir.join("SKILL.md"), rendered)?; |
| imported.push(name); |
| } |
|
|
| Ok(imported) |
| } |
|
|
| fn unique_supported_command_sources( |
| source_commands: &Path, |
| description_mode: CommandDescriptionMode, |
| ) -> io::Result<Vec<CommandSource>> { |
| Ok(unique_command_sources(supported_command_sources( |
| source_commands, |
| description_mode, |
| )?)) |
| } |
|
|
| fn supported_command_sources( |
| source_commands: &Path, |
| description_mode: CommandDescriptionMode, |
| ) -> io::Result<Vec<CommandSource>> { |
| let mut sources = Vec::new(); |
| for source_file in command_source_files(source_commands)? { |
| let document = parse_command(&source_file)?; |
| let source_name = command_source_name(source_commands, &source_file); |
| let Some(name) = command_skill_name_if_supported( |
| &source_name, |
| &source_file, |
| &document, |
| description_mode, |
| ) else { |
| continue; |
| }; |
| sources.push(CommandSource { |
| source_file, |
| name, |
| source_name, |
| }); |
| } |
| Ok(sources) |
| } |
|
|
| fn unique_command_sources(command_sources: Vec<CommandSource>) -> Vec<CommandSource> { |
| let mut by_name = BTreeMap::<String, BTreeMap<PathBuf, String>>::new(); |
| for source in command_sources { |
| by_name |
| .entry(source.name) |
| .or_default() |
| .insert(source.source_file, source.source_name); |
| } |
|
|
| by_name |
| .into_iter() |
| .filter_map(|(name, source_files)| { |
| let mut source_files = source_files.into_iter(); |
| let (source_file, source_name) = source_files.next()?; |
| if source_files.next().is_some() { |
| return None; |
| } |
| Some(CommandSource { |
| source_file, |
| name, |
| source_name, |
| }) |
| }) |
| .collect() |
| } |
|
|
| fn command_source_files(source_commands: &Path) -> io::Result<Vec<PathBuf>> { |
| if source_commands.is_file() { |
| return Ok( |
| if source_commands.extension().and_then(|ext| ext.to_str()) == Some("md") { |
| vec![source_commands.to_path_buf()] |
| } else { |
| Vec::new() |
| }, |
| ); |
| } |
|
|
| let mut files = Vec::new(); |
| collect_markdown_files(source_commands, &mut files)?; |
| files.sort(); |
| Ok(files) |
| } |
|
|
| fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> { |
| if !dir.is_dir() { |
| return Ok(()); |
| } |
|
|
| for entry in fs::read_dir(dir)? { |
| let entry = entry?; |
| let path = entry.path(); |
| let file_type = entry.file_type()?; |
| if file_type.is_dir() { |
| collect_markdown_files(&path, files)?; |
| } else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") |
| { |
| files.push(path); |
| } |
| } |
| Ok(()) |
| } |
|
|
| fn parse_command(source_file: &Path) -> io::Result<ParsedCommand> { |
| Ok(parse_command_content(&fs::read_to_string(source_file)?)) |
| } |
|
|
| fn parse_command_content(content: &str) -> ParsedCommand { |
| let Some(rest) = content |
| .strip_prefix("---\n") |
| .or_else(|| content.strip_prefix("---\r\n")) |
| else { |
| return ParsedCommand { |
| description: None, |
| body: content.to_string(), |
| }; |
| }; |
| let Some((end, body_start)) = frontmatter_end(rest) else { |
| return ParsedCommand { |
| description: None, |
| body: content.to_string(), |
| }; |
| }; |
|
|
| ParsedCommand { |
| description: parse_command_description(&rest[..end]), |
| body: rest[body_start..].to_string(), |
| } |
| } |
|
|
| fn frontmatter_end(rest: &str) -> Option<(usize, usize)> { |
| [ |
| "\r\n---\r\n", |
| "\r\n---\n", |
| "\n---\r\n", |
| "\n---\n", |
| "\r\n---", |
| "\n---", |
| ] |
| .into_iter() |
| .filter_map(|delimiter| rest.find(delimiter).map(|end| (end, end + delimiter.len()))) |
| .min_by_key(|(end, _body_start)| *end) |
| } |
|
|
| fn parse_command_description(raw_frontmatter: &str) -> Option<String> { |
| let parsed: YamlValue = serde_yaml::from_str(raw_frontmatter).ok()?; |
| let mapping = parsed.as_mapping()?; |
| mapping.iter().find_map(|(key, value)| { |
| if key.as_str()?.trim() == "description" { |
| yaml_scalar(value) |
| } else { |
| None |
| } |
| }) |
| } |
|
|
| fn yaml_scalar(value: &YamlValue) -> Option<String> { |
| match value { |
| YamlValue::String(value) => Some(value.trim().to_string()), |
| YamlValue::Bool(value) => Some(value.to_string()), |
| YamlValue::Number(value) => Some(value.to_string()), |
| YamlValue::Null | YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => { |
| None |
| } |
| } |
| } |
|
|
| fn command_skill_name(source_name: &str) -> String { |
| slugify_name(&format!("{COMMAND_SKILL_PREFIX}-{source_name}")) |
| } |
|
|
| fn command_skill_name_if_supported( |
| source_name: &str, |
| source_file: &Path, |
| document: &ParsedCommand, |
| description_mode: CommandDescriptionMode, |
| ) -> Option<String> { |
| if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") { |
| return None; |
| } |
| command_skill_description(document, source_name, description_mode)?; |
| let name = command_skill_name(source_name); |
| if name.chars().count() > MAX_SKILL_NAME_LEN |
| || has_unsupported_command_template_features(&document.body) |
| { |
| return None; |
| } |
| Some(name) |
| } |
|
|
| fn command_skill_description( |
| document: &ParsedCommand, |
| source_name: &str, |
| description_mode: CommandDescriptionMode, |
| ) -> Option<String> { |
| document |
| .description |
| .as_deref() |
| .filter(|value| !value.trim().is_empty()) |
| .map(ToOwned::to_owned) |
| .or_else(|| match description_mode { |
| CommandDescriptionMode::RequireFrontmatter => None, |
| CommandDescriptionMode::UseSourceNameFallback => { |
| Some(format!("Migrated source command `{source_name}`")) |
| } |
| }) |
| } |
|
|
| fn command_source_name(source_commands: &Path, source_file: &Path) -> String { |
| if source_commands.is_file() { |
| return source_file |
| .file_stem() |
| .and_then(|stem| stem.to_str()) |
| .unwrap_or_default() |
| .to_string(); |
| } |
| source_file |
| .strip_prefix(source_commands) |
| .unwrap_or(source_file) |
| .with_extension("") |
| .components() |
| .filter_map(|component| component.as_os_str().to_str()) |
| .collect::<Vec<_>>() |
| .join("-") |
| } |
|
|
| fn render_command_skill( |
| body: &str, |
| name: &str, |
| description: &str, |
| source_name: &str, |
| rewrite_profile: RewriteProfile, |
| ) -> String { |
| let body = rewrite_terms(body.trim(), rewrite_profile); |
| let template_body = if body.is_empty() { |
| "No command template body was found.".to_string() |
| } else { |
| body |
| }; |
| format!( |
| "---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n", |
| yaml_string(name), |
| yaml_string(&rewrite_terms(description, rewrite_profile)), |
| ) |
| } |
|
|
| fn has_unsupported_command_template_features(template: &str) -> bool { |
| template.contains("$ARGUMENTS") |
| || contains_numbered_argument_placeholder(template) |
| || (template.contains("{{") && template.contains("}}")) |
| || template.contains("!`") |
| || template.contains("! `") |
| || template |
| .split_whitespace() |
| .any(|token| token.strip_prefix('@').is_some_and(|rest| !rest.is_empty())) |
| } |
|
|
| fn contains_numbered_argument_placeholder(template: &str) -> bool { |
| let bytes = template.as_bytes(); |
| bytes |
| .windows(2) |
| .any(|window| window[0] == b'$' && window[1].is_ascii_digit()) |
| } |
|
|
| #[cfg(test)] |
| #[path = "command_migration_tests.rs"] |
| mod tests; |
|
|