use std::path::Path;
use libaipm::manifest::types::PluginType;
pub use libaipm::wizard::{styled_render_config, PromptAnswer, PromptKind, PromptStep};
const PLUGIN_TYPE_OPTIONS: [&str; 6] = [
"composite \u{2014} skills + agents + hooks (recommended)",
"skill \u{2014} single skill",
"agent \u{2014} autonomous agent",
"mcp \u{2014} Model Context Protocol server",
"hook \u{2014} lifecycle hook",
"lsp \u{2014} Language Server Protocol",
];
const fn plugin_type_from_index(index: usize) -> Option<PluginType> {
match index {
0 => Some(PluginType::Composite),
1 => Some(PluginType::Skill),
2 => Some(PluginType::Agent),
3 => Some(PluginType::Mcp),
4 => Some(PluginType::Hook),
5 => Some(PluginType::Lsp),
_ => None,
}
}
pub fn package_prompt_steps(
dir: &Path,
flag_name: Option<&str>,
flag_type: Option<PluginType>,
) -> Vec<PromptStep> {
let mut steps = Vec::new();
if flag_name.is_none() {
let placeholder = dir
.file_name()
.and_then(|n| n.to_str())
.map_or_else(|| "my-plugin".to_string(), String::from);
steps.push(PromptStep {
label: "Package name:",
kind: PromptKind::Text { placeholder, validate: true },
help: Some("Lowercase alphanumeric with hyphens, or @org/name"),
});
}
steps.push(PromptStep {
label: "Description:",
kind: PromptKind::Text { placeholder: "An AI plugin package".to_string(), validate: false },
help: None,
});
if flag_type.is_none() {
steps.push(PromptStep {
label: "Plugin type:",
kind: PromptKind::Select { options: PLUGIN_TYPE_OPTIONS.to_vec(), default_index: 0 },
help: Some("Use arrow keys, Enter to select"),
});
}
steps
}
pub fn resolve_package_answers(
answers: &[PromptAnswer],
flag_name: Option<&str>,
flag_type: Option<PluginType>,
) -> (Option<String>, Option<PluginType>) {
let mut idx = 0;
let name = flag_name.map_or_else(
|| {
let result = match answers.get(idx) {
Some(PromptAnswer::Text(t)) if t.is_empty() => None,
Some(PromptAnswer::Text(t)) => Some(t.clone()),
_ => None,
};
idx += 1;
result
},
|n| Some(n.to_string()),
);
idx += 1;
let plugin_type = flag_type.map_or_else(
|| match answers.get(idx) {
Some(PromptAnswer::Selected(i)) => plugin_type_from_index(*i),
_ => Some(PluginType::Composite),
},
Some,
);
(name, plugin_type)
}
#[cfg(test)]
mod tests {
use super::*;
fn format_steps(steps: &[PromptStep]) -> String {
let mut out = String::new();
if steps.is_empty() {
out.push_str("(no prompts)\n");
return out;
}
for (i, step) in steps.iter().enumerate() {
out.push_str(&format!("Step {}:\n", i + 1));
out.push_str(&format!(" Label: {}\n", step.label));
match &step.kind {
PromptKind::Select { options, default_index } => {
out.push_str(&format!(" Kind: Select (default: {})\n", default_index));
for (j, opt) in options.iter().enumerate() {
let marker = if j == *default_index { " *" } else { " " };
out.push_str(&format!(" {}[{}] {}\n", marker, j, opt));
}
},
PromptKind::Confirm { default } => {
out.push_str(&format!(" Kind: Confirm (default: {})\n", default));
},
PromptKind::Text { placeholder, validate } => {
out.push_str(&format!(" Kind: Text (placeholder: \"{}\")\n", placeholder));
if *validate {
out.push_str(" Validate: package-name\n");
}
},
}
if let Some(help) = step.help {
out.push_str(&format!(" Help: {}\n", help));
}
out.push('\n');
}
out
}
#[test]
fn package_prompts_no_flags_snapshot() {
let dir = std::path::Path::new("/projects/my-cool-project");
let steps = package_prompt_steps(dir, None, None);
insta::assert_snapshot!(format_steps(&steps));
}
#[test]
fn package_prompts_name_flag_snapshot() {
let dir = std::path::Path::new("/projects/my-cool-project");
let steps = package_prompt_steps(dir, Some("custom-name"), None);
insta::assert_snapshot!(format_steps(&steps));
}
#[test]
fn package_prompts_type_flag_snapshot() {
let dir = std::path::Path::new("/projects/my-cool-project");
let steps = package_prompt_steps(dir, None, Some(PluginType::Skill));
insta::assert_snapshot!(format_steps(&steps));
}
#[test]
fn package_prompts_all_flags_snapshot() {
let dir = std::path::Path::new("/projects/my-cool-project");
let steps = package_prompt_steps(dir, Some("foo"), Some(PluginType::Mcp));
insta::assert_snapshot!(format_steps(&steps));
}
#[test]
fn package_prompts_placeholder_uses_dir_name() {
let dir = std::path::Path::new("/projects/my-cool-project");
let steps = package_prompt_steps(dir, None, None);
let name_step = &steps[0];
match &name_step.kind {
PromptKind::Text { placeholder, .. } => {
assert_eq!(placeholder, "my-cool-project");
},
_ => {
assert!(false, "expected Text prompt for package name");
},
}
}
#[test]
fn resolve_package_defaults_snapshot() {
let answers = vec![
PromptAnswer::Text(String::new()), PromptAnswer::Text(String::new()), PromptAnswer::Selected(0), ];
let result = resolve_package_answers(&answers, None, None);
insta::assert_snapshot!(format!("{:?}", result));
}
#[test]
fn resolve_package_custom_name_snapshot() {
let answers = vec![
PromptAnswer::Text("my-plugin".to_string()),
PromptAnswer::Text("A cool plugin".to_string()),
PromptAnswer::Selected(1), ];
let result = resolve_package_answers(&answers, None, None);
insta::assert_snapshot!(format!("{:?}", result));
}
#[test]
fn resolve_package_each_type_snapshot() {
let types = ["Composite", "Skill", "Agent", "Mcp", "Hook", "Lsp"];
let mut out = String::new();
for (i, label) in types.iter().enumerate() {
let answers = vec![
PromptAnswer::Text(String::new()),
PromptAnswer::Text(String::new()),
PromptAnswer::Selected(i),
];
let (_, pt) = resolve_package_answers(&answers, None, None);
out.push_str(&format!("index {} -> {:?} (expected {})\n", i, pt, label));
}
insta::assert_snapshot!(out);
}
#[test]
fn resolve_package_with_name_flag_snapshot() {
let answers = vec![
PromptAnswer::Text(String::new()), PromptAnswer::Selected(2), ];
let result = resolve_package_answers(&answers, Some("preset-name"), None);
insta::assert_snapshot!(format!("{:?}", result));
}
#[test]
fn resolve_package_with_type_flag_snapshot() {
let answers =
vec![PromptAnswer::Text("custom".to_string()), PromptAnswer::Text(String::new())];
let result = resolve_package_answers(&answers, None, Some(PluginType::Agent));
insta::assert_snapshot!(format!("{:?}", result));
}
#[test]
fn resolve_package_with_both_flags_snapshot() {
let answers = vec![PromptAnswer::Text("desc".to_string())];
let result = resolve_package_answers(&answers, Some("preset"), Some(PluginType::Hook));
insta::assert_snapshot!(format!("{:?}", result));
}
fn validate_name_interactive(input: &str) -> Result<(), String> {
libaipm::manifest::validate::check_name(
input,
libaipm::manifest::validate::ValidationMode::Interactive,
)
}
#[test]
fn validate_package_name_accepts_lowercase() {
assert!(validate_name_interactive("my-plugin").is_ok());
}
#[test]
fn validate_package_name_accepts_scoped() {
assert!(validate_name_interactive("@org/my-plugin").is_ok());
}
#[test]
fn validate_package_name_accepts_empty_for_default() {
assert!(validate_name_interactive("").is_ok());
}
#[test]
fn validate_package_name_accepts_digits() {
assert!(validate_name_interactive("123abc").is_ok());
}
#[test]
fn validate_package_name_rejects_uppercase() {
assert!(validate_name_interactive("MyPlugin").is_err());
}
#[test]
fn validate_package_name_rejects_spaces() {
assert!(validate_name_interactive("my plugin").is_err());
}
#[test]
fn validate_package_name_rejects_special_chars() {
assert!(validate_name_interactive("my_plugin!").is_err());
}
#[test]
fn validate_package_name_rejects_underscores() {
assert!(validate_name_interactive("my_plugin").is_err());
}
#[test]
fn styled_render_config_snapshot() {
let config = styled_render_config();
let summary = format!(
"prompt_prefix: {:?}\nanswered_prefix: {:?}\nplaceholder: {:?}",
config.prompt_prefix, config.answered_prompt_prefix, config.placeholder,
);
insta::assert_snapshot!(summary);
}
#[test]
fn plugin_type_from_index_out_of_range() {
assert!(plugin_type_from_index(6).is_none());
assert!(plugin_type_from_index(100).is_none());
}
#[test]
fn plugin_type_from_index_all_valid() {
assert_eq!(plugin_type_from_index(0), Some(PluginType::Composite));
assert_eq!(plugin_type_from_index(1), Some(PluginType::Skill));
assert_eq!(plugin_type_from_index(2), Some(PluginType::Agent));
assert_eq!(plugin_type_from_index(3), Some(PluginType::Mcp));
assert_eq!(plugin_type_from_index(4), Some(PluginType::Hook));
assert_eq!(plugin_type_from_index(5), Some(PluginType::Lsp));
}
#[test]
fn format_steps_empty_input_returns_no_prompts_label() {
let result = format_steps(&[]);
assert_eq!(result, "(no prompts)\n");
}
}