Skip to main content

codineer_tools/
lib.rs

1use std::path::Path;
2use std::process::Command;
3use std::time::{Duration, Instant};
4
5use serde::Deserialize;
6use serde_json::Value;
7
8use runtime::{
9    edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
10    GrepSearchInput,
11};
12
13mod agent;
14mod config_tool;
15mod notebook;
16mod powershell;
17mod registry;
18mod specs;
19mod types;
20mod web;
21
22pub use registry::{GlobalToolRegistry, ToolManifestEntry, ToolRegistry, ToolSource, ToolSpec};
23pub use specs::mvp_tool_specs;
24pub use types::ToolSearchInput;
25
26#[cfg(test)]
27pub(crate) use agent::{
28    agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
29    final_assistant_text, persist_agent_terminal_state, push_output_block, SubagentToolExecutor,
30};
31pub(crate) use types::AgentInput;
32#[cfg(test)]
33pub(crate) use types::AgentJob;
34
35use crate::types::{
36    AskUserQuestionInput, AskUserQuestionOutput, BriefInput, BriefOutput, BriefStatus, ConfigInput,
37    EditFileInput, GlobSearchInputValue, MultiEditInput, MultiEditOutput, NotebookEditInput,
38    PowerShellInput, QuestionOption, ReadFileInput, ReplInput, ReplOutput, ResolvedAttachment,
39    SkillInput, SkillOutput, SleepInput, SleepOutput, StructuredOutputInput,
40    StructuredOutputResult, TodoItem, TodoStatus, TodoWriteInput, TodoWriteOutput,
41    ToolSearchOutput, UserQuestion, WebFetchInput, WebSearchInput, WriteFileInput,
42};
43
44pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
45    match name {
46        "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
47        "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
48        "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
49        "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
50        "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
51        "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
52        "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
53        "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
54        "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
55        "Skill" => from_value::<SkillInput>(input).and_then(run_skill),
56        "Agent" => from_value::<AgentInput>(input).and_then(run_agent),
57        "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search),
58        "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit),
59        "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
60        "SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
61        "Config" => from_value::<ConfigInput>(input).and_then(run_config),
62        "StructuredOutput" => {
63            from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
64        }
65        "REPL" => from_value::<ReplInput>(input).and_then(run_repl),
66        "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
67        "MultiEdit" => from_value::<MultiEditInput>(input).and_then(run_multi_edit),
68        "AskUserQuestion" => {
69            from_value::<AskUserQuestionInput>(input).and_then(run_ask_user_question)
70        }
71        _ => Err(format!("unsupported tool: {name}")),
72    }
73}
74
75fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
76    serde_json::from_value(input.clone()).map_err(|error| error.to_string())
77}
78
79fn run_bash(input: BashCommandInput) -> Result<String, String> {
80    serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
81        .map_err(|error| error.to_string())
82}
83
84#[allow(clippy::needless_pass_by_value)]
85fn run_read_file(input: ReadFileInput) -> Result<String, String> {
86    to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
87}
88
89#[allow(clippy::needless_pass_by_value)]
90fn run_write_file(input: WriteFileInput) -> Result<String, String> {
91    to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
92}
93
94#[allow(clippy::needless_pass_by_value)]
95fn run_edit_file(input: EditFileInput) -> Result<String, String> {
96    to_pretty_json(
97        edit_file(
98            &input.path,
99            &input.old_string,
100            &input.new_string,
101            input.replace_all.unwrap_or(false),
102        )
103        .map_err(io_to_string)?,
104    )
105}
106
107#[allow(clippy::needless_pass_by_value)]
108fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
109    to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
110}
111
112#[allow(clippy::needless_pass_by_value)]
113fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
114    to_pretty_json(grep_search(&input).map_err(io_to_string)?)
115}
116
117#[allow(clippy::needless_pass_by_value)]
118fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
119    to_pretty_json(crate::web::execute_web_fetch(&input)?)
120}
121
122#[allow(clippy::needless_pass_by_value)]
123fn run_web_search(input: WebSearchInput) -> Result<String, String> {
124    to_pretty_json(crate::web::execute_web_search(&input)?)
125}
126
127fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
128    to_pretty_json(execute_todo_write(input)?)
129}
130
131fn run_skill(input: SkillInput) -> Result<String, String> {
132    to_pretty_json(execute_skill(input)?)
133}
134
135fn run_agent(input: AgentInput) -> Result<String, String> {
136    to_pretty_json(crate::agent::execute_agent(input)?)
137}
138
139fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
140    to_pretty_json(execute_tool_search(input))
141}
142
143fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
144    to_pretty_json(crate::notebook::execute_notebook_edit(input)?)
145}
146
147fn run_sleep(input: SleepInput) -> Result<String, String> {
148    to_pretty_json(execute_sleep(input))
149}
150
151fn run_brief(input: BriefInput) -> Result<String, String> {
152    to_pretty_json(execute_brief(input)?)
153}
154
155fn run_config(input: ConfigInput) -> Result<String, String> {
156    to_pretty_json(crate::config_tool::execute_config(input)?)
157}
158
159fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
160    to_pretty_json(execute_structured_output(input))
161}
162
163fn run_repl(input: ReplInput) -> Result<String, String> {
164    to_pretty_json(execute_repl(input)?)
165}
166
167fn run_powershell(input: PowerShellInput) -> Result<String, String> {
168    to_pretty_json(crate::powershell::execute_powershell(input).map_err(|error| error.to_string())?)
169}
170
171fn run_multi_edit(input: MultiEditInput) -> Result<String, String> {
172    to_pretty_json(execute_multi_edit(input)?)
173}
174
175fn run_ask_user_question(input: AskUserQuestionInput) -> Result<String, String> {
176    to_pretty_json(execute_ask_user_question(input)?)
177}
178
179fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
180    serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
181}
182
183#[allow(clippy::needless_pass_by_value)]
184fn io_to_string(error: std::io::Error) -> String {
185    error.to_string()
186}
187
188fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
189    validate_todos(&input.todos)?;
190    let store_path = todo_store_path()?;
191    let old_todos = if store_path.exists() {
192        serde_json::from_str::<Vec<TodoItem>>(
193            &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
194        )
195        .map_err(|error| error.to_string())?
196    } else {
197        Vec::new()
198    };
199
200    let all_done = input
201        .todos
202        .iter()
203        .all(|todo| matches!(todo.status, TodoStatus::Completed));
204    let persisted = if all_done {
205        Vec::new()
206    } else {
207        input.todos.clone()
208    };
209
210    if let Some(parent) = store_path.parent() {
211        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
212    }
213    std::fs::write(
214        &store_path,
215        serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
216    )
217    .map_err(|error| error.to_string())?;
218
219    let verification_nudge_needed = (all_done
220        && input.todos.len() >= 3
221        && !input
222            .todos
223            .iter()
224            .any(|todo| todo.content.to_lowercase().contains("verif")))
225    .then_some(true);
226
227    Ok(TodoWriteOutput {
228        old_todos,
229        new_todos: input.todos,
230        verification_nudge_needed,
231    })
232}
233
234fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
235    let skill_path = resolve_skill_path(&input.skill)?;
236    let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
237    let description = parse_skill_description(&prompt);
238
239    Ok(SkillOutput {
240        skill: input.skill,
241        path: skill_path.display().to_string(),
242        args: input.args,
243        description,
244        prompt,
245    })
246}
247
248fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
249    if todos.is_empty() {
250        return Err(String::from("todos must not be empty"));
251    }
252    // Allow multiple in_progress items for parallel workflows
253    if todos.iter().any(|todo| todo.content.trim().is_empty()) {
254        return Err(String::from("todo content must not be empty"));
255    }
256    if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
257        return Err(String::from("todo activeForm must not be empty"));
258    }
259    Ok(())
260}
261
262fn todo_store_path() -> Result<std::path::PathBuf, String> {
263    if let Ok(path) = std::env::var("CODINEER_TODO_STORE") {
264        return Ok(std::path::PathBuf::from(path));
265    }
266    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
267    Ok(runtime::codineer_runtime_dir(&cwd).join("todos.json"))
268}
269
270fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
271    let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
272    if requested.is_empty() {
273        return Err(String::from("skill must not be empty"));
274    }
275
276    if requested.contains("..") || requested.contains('/') || requested.contains('\\') {
277        return Err(format!(
278            "invalid skill name '{requested}': must not contain path separators or '..'"
279        ));
280    }
281
282    let mut candidates = Vec::new();
283    if let Ok(cwd) = std::env::current_dir() {
284        candidates.push(runtime::codineer_runtime_dir(&cwd).join("skills"));
285    }
286    if let Ok(codineer_home) = std::env::var("CODINEER_CONFIG_HOME") {
287        candidates.push(std::path::PathBuf::from(codineer_home).join("skills"));
288    }
289    if let Some(home) = runtime::home_dir() {
290        candidates.push(home.join(".codineer").join("skills"));
291    }
292
293    for root in candidates {
294        let direct = root.join(requested).join("SKILL.md");
295        if direct.exists() {
296            return Ok(direct);
297        }
298
299        if let Ok(entries) = std::fs::read_dir(&root) {
300            for entry in entries.flatten() {
301                let path = entry.path().join("SKILL.md");
302                if !path.exists() {
303                    continue;
304                }
305                if entry
306                    .file_name()
307                    .to_string_lossy()
308                    .eq_ignore_ascii_case(requested)
309                {
310                    return Ok(path);
311                }
312            }
313        }
314    }
315
316    Err(format!("unknown skill: {requested}"))
317}
318
319fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
320    execute_tool_search_with_context(input, None)
321}
322
323pub fn execute_tool_search_with_context(
324    input: ToolSearchInput,
325    pending_mcp_servers: Option<Vec<String>>,
326) -> ToolSearchOutput {
327    let deferred = deferred_tool_specs();
328    let max_results = input.max_results.unwrap_or(5).max(1);
329    let query = input.query.trim().to_string();
330    let normalized_query = normalize_tool_search_query(&query);
331    let matches = search_tool_specs(&query, max_results, &deferred);
332
333    ToolSearchOutput {
334        matches,
335        query,
336        normalized_query,
337        total_deferred_tools: deferred.len(),
338        pending_mcp_servers: pending_mcp_servers.filter(|servers| !servers.is_empty()),
339    }
340}
341
342fn deferred_tool_specs() -> Vec<ToolSpec> {
343    mvp_tool_specs()
344        .into_iter()
345        .filter(|spec| {
346            !matches!(
347                spec.name,
348                "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
349            )
350        })
351        .collect()
352}
353
354fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
355    if query.trim().is_empty() {
356        return Vec::new();
357    }
358    let lowered = query.to_lowercase();
359    if let Some(selection) = lowered.strip_prefix("select:") {
360        return selection
361            .split(',')
362            .map(str::trim)
363            .filter(|part| !part.is_empty())
364            .filter_map(|wanted| {
365                let wanted = canonical_tool_token(wanted);
366                specs
367                    .iter()
368                    .find(|spec| canonical_tool_token(spec.name) == wanted)
369                    .map(|spec| spec.name.to_string())
370            })
371            .take(max_results)
372            .collect();
373    }
374
375    let mut required = Vec::new();
376    let mut optional = Vec::new();
377    for term in lowered.split_whitespace() {
378        if let Some(rest) = term.strip_prefix('+') {
379            if !rest.is_empty() {
380                required.push(rest);
381            }
382        } else {
383            optional.push(term);
384        }
385    }
386    let terms = if required.is_empty() {
387        optional.clone()
388    } else {
389        required.iter().chain(optional.iter()).copied().collect()
390    };
391
392    let mut scored = specs
393        .iter()
394        .filter_map(|spec| {
395            let name = spec.name.to_lowercase();
396            let canonical_name = canonical_tool_token(spec.name);
397            let normalized_description = normalize_tool_search_query(spec.description);
398            let haystack = format!(
399                "{name} {} {canonical_name}",
400                spec.description.to_lowercase()
401            );
402            let normalized_haystack = format!("{canonical_name} {normalized_description}");
403            if required.iter().any(|term| !haystack.contains(term)) {
404                return None;
405            }
406
407            let mut score = 0_i32;
408            for term in &terms {
409                let canonical_term = canonical_tool_token(term);
410                if haystack.contains(term) {
411                    score += 2;
412                }
413                if name == *term {
414                    score += 8;
415                }
416                if name.contains(term) {
417                    score += 4;
418                }
419                if canonical_name == canonical_term {
420                    score += 12;
421                }
422                if normalized_haystack.contains(&canonical_term) {
423                    score += 3;
424                }
425            }
426
427            if score == 0 && !lowered.is_empty() {
428                return None;
429            }
430            Some((score, spec.name.to_string()))
431        })
432        .collect::<Vec<_>>();
433
434    scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
435    scored
436        .into_iter()
437        .map(|(_, name)| name)
438        .take(max_results)
439        .collect()
440}
441
442fn normalize_tool_search_query(query: &str) -> String {
443    query
444        .trim()
445        .split(|ch: char| ch.is_whitespace() || ch == ',')
446        .filter(|term| !term.is_empty())
447        .map(canonical_tool_token)
448        .collect::<Vec<_>>()
449        .join(" ")
450}
451
452pub(crate) fn canonical_tool_token(value: &str) -> String {
453    let mut canonical = value
454        .chars()
455        .filter(char::is_ascii_alphanumeric)
456        .flat_map(char::to_lowercase)
457        .collect::<String>();
458    if let Some(stripped) = canonical.strip_suffix("tool") {
459        canonical = stripped.to_string();
460    }
461    canonical
462}
463
464#[cfg(test)]
465pub(crate) const MAX_SLEEP_MS: u64 = 5 * 60 * 1000;
466#[cfg(not(test))]
467const MAX_SLEEP_MS: u64 = 5 * 60 * 1000;
468
469#[cfg(test)]
470pub(crate) fn clamp_sleep(requested_ms: u64) -> (u64, String) {
471    clamp_sleep_inner(requested_ms)
472}
473
474fn clamp_sleep_inner(requested_ms: u64) -> (u64, String) {
475    let clamped = requested_ms.min(MAX_SLEEP_MS);
476    let message = if clamped < requested_ms {
477        format!("Slept for {clamped}ms (clamped from {requested_ms}ms)")
478    } else {
479        format!("Slept for {clamped}ms")
480    };
481    (clamped, message)
482}
483
484#[allow(clippy::needless_pass_by_value)]
485fn execute_sleep(input: SleepInput) -> SleepOutput {
486    let (duration_ms, message) = clamp_sleep_inner(input.duration_ms);
487    std::thread::sleep(Duration::from_millis(duration_ms));
488    SleepOutput {
489        duration_ms,
490        message,
491    }
492}
493
494fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
495    if input.message.trim().is_empty() {
496        return Err(String::from("message must not be empty"));
497    }
498
499    let attachments = input
500        .attachments
501        .as_ref()
502        .map(|paths| {
503            paths
504                .iter()
505                .map(|path| resolve_attachment(path))
506                .collect::<Result<Vec<_>, String>>()
507        })
508        .transpose()?;
509
510    let message = match input.status {
511        BriefStatus::Normal | BriefStatus::Proactive => input.message,
512    };
513
514    Ok(BriefOutput {
515        message,
516        attachments,
517        sent_at: crate::config_tool::iso8601_timestamp(),
518    })
519}
520
521fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
522    let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
523    let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
524    Ok(ResolvedAttachment {
525        path: resolved.display().to_string(),
526        size: metadata.len(),
527        is_image: is_image_path(&resolved),
528    })
529}
530
531fn is_image_path(path: &Path) -> bool {
532    matches!(
533        path.extension()
534            .and_then(|ext| ext.to_str())
535            .map(str::to_ascii_lowercase)
536            .as_deref(),
537        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
538    )
539}
540
541fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
542    StructuredOutputResult {
543        data: String::from("Structured output provided successfully"),
544        structured_output: input.0,
545    }
546}
547
548fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
549    if input.code.trim().is_empty() {
550        return Err(String::from("code must not be empty"));
551    }
552    let timeout_ms = input.timeout_ms.unwrap_or(30_000).max(1_000);
553    let runtime = resolve_repl_runtime(&input.language)?;
554    let started = Instant::now();
555    let child = Command::new(runtime.program)
556        .args(runtime.args)
557        .arg(&input.code)
558        .stdout(std::process::Stdio::piped())
559        .stderr(std::process::Stdio::piped())
560        .spawn()
561        .map_err(|error| error.to_string())?;
562
563    let pid = child.id();
564    let timeout = Duration::from_millis(timeout_ms);
565    let (tx, rx) = std::sync::mpsc::channel();
566    std::thread::spawn(move || {
567        let _ = tx.send(child.wait_with_output());
568    });
569
570    match rx.recv_timeout(timeout) {
571        Ok(Ok(output)) => Ok(ReplOutput {
572            language: input.language,
573            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
574            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
575            exit_code: output.status.code().unwrap_or(1),
576            duration_ms: started.elapsed().as_millis(),
577        }),
578        Ok(Err(error)) => Err(error.to_string()),
579        Err(_) => {
580            kill_process(pid);
581            Ok(ReplOutput {
582                language: input.language,
583                stdout: String::new(),
584                stderr: format!("REPL execution timed out after {timeout_ms}ms"),
585                exit_code: 124,
586                duration_ms: started.elapsed().as_millis(),
587            })
588        }
589    }
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593enum ReplLanguage {
594    Python,
595    JavaScript,
596    Shell,
597}
598
599impl ReplLanguage {
600    fn parse(input: &str) -> Result<Self, String> {
601        match input.trim().to_ascii_lowercase().as_str() {
602            "python" | "py" => Ok(Self::Python),
603            "javascript" | "js" | "node" => Ok(Self::JavaScript),
604            "sh" | "shell" | "bash" => Ok(Self::Shell),
605            other => Err(format!("unsupported REPL language: {other}")),
606        }
607    }
608
609    fn command_candidates(self) -> &'static [&'static str] {
610        match self {
611            Self::Python => &["python3", "python"],
612            Self::JavaScript => &["node"],
613            Self::Shell => &["bash", "sh"],
614        }
615    }
616
617    fn eval_args(self) -> &'static [&'static str] {
618        match self {
619            Self::Python => &["-c"],
620            Self::JavaScript => &["-e"],
621            Self::Shell => &["-lc"],
622        }
623    }
624}
625
626struct ReplRuntime {
627    program: &'static str,
628    args: &'static [&'static str],
629}
630
631fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
632    let lang = ReplLanguage::parse(language)?;
633    let program = detect_first_command(lang.command_candidates())
634        .ok_or_else(|| format!("{language} runtime not found"))?;
635    Ok(ReplRuntime {
636        program,
637        args: lang.eval_args(),
638    })
639}
640
641fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
642    commands
643        .iter()
644        .copied()
645        .find(|command| crate::powershell::command_exists(command))
646}
647
648fn parse_skill_description(contents: &str) -> Option<String> {
649    for line in contents.lines() {
650        if let Some(value) = line.strip_prefix("description:") {
651            let trimmed = value.trim();
652            if !trimmed.is_empty() {
653                return Some(trimmed.to_string());
654            }
655        }
656    }
657    None
658}
659
660fn execute_multi_edit(input: MultiEditInput) -> Result<MultiEditOutput, String> {
661    if input.edits.is_empty() {
662        return Err(String::from("edits must not be empty"));
663    }
664    for (i, op) in input.edits.iter().enumerate() {
665        edit_file(
666            &input.path,
667            &op.old_string,
668            &op.new_string,
669            op.replace_all.unwrap_or(false),
670        )
671        .map_err(|error| format!("edit[{i}] failed: {error}"))?;
672    }
673    Ok(MultiEditOutput {
674        path: input.path,
675        edits_applied: input.edits.len(),
676    })
677}
678
679fn execute_ask_user_question(input: AskUserQuestionInput) -> Result<AskUserQuestionOutput, String> {
680    if input.questions.is_empty() {
681        return Err(String::from("questions must not be empty"));
682    }
683    if input.questions.len() > 4 {
684        return Err(String::from("at most 4 questions are allowed per call"));
685    }
686    for (qi, q) in input.questions.iter().enumerate() {
687        if q.question.trim().is_empty() {
688            return Err(format!("questions[{qi}].question must not be empty"));
689        }
690        if q.options.len() < 2 {
691            return Err(format!(
692                "questions[{qi}] must have at least 2 options, got {}",
693                q.options.len()
694            ));
695        }
696        if q.options.len() > 26 {
697            return Err(format!(
698                "questions[{qi}] must have at most 26 options, got {}",
699                q.options.len()
700            ));
701        }
702    }
703
704    let formatted_message = format_questions(&input.questions);
705    Ok(AskUserQuestionOutput {
706        questions: input.questions,
707        formatted_message,
708        pending_user_response: true,
709    })
710}
711
712fn format_questions(questions: &[UserQuestion]) -> String {
713    let mut out = String::from("Please answer the following question(s):\n\n");
714    for (i, q) in questions.iter().enumerate() {
715        if let Some(header) = &q.header {
716            out.push_str(&format!("**{}**\n", header));
717        }
718        let select_hint = if q.multi_select {
719            " (select one or more)"
720        } else {
721            " (select one)"
722        };
723        out.push_str(&format!("{}. {}{}\n", i + 1, q.question, select_hint));
724        for (oi, opt) in q.options.iter().enumerate() {
725            out.push_str(&format_option(oi, opt));
726        }
727        out.push('\n');
728    }
729    out.trim_end().to_string()
730}
731
732fn format_option(index: usize, opt: &QuestionOption) -> String {
733    // index is validated to be 0..=25 by execute_ask_user_question
734    let letter = char::from(b'a' + index as u8);
735    match &opt.description {
736        Some(desc) if !desc.trim().is_empty() => {
737            format!("  {letter}) {} — {}\n", opt.label, desc)
738        }
739        _ => format!("  {letter}) {}\n", opt.label),
740    }
741}
742
743pub(crate) fn kill_process(pid: u32) {
744    #[cfg(unix)]
745    {
746        let _ = Command::new("kill")
747            .args(["-9", &pid.to_string()])
748            .stdout(std::process::Stdio::null())
749            .stderr(std::process::Stdio::null())
750            .status();
751    }
752    #[cfg(windows)]
753    {
754        let _ = Command::new("taskkill")
755            .args(["/F", "/PID", &pid.to_string()])
756            .stdout(std::process::Stdio::null())
757            .stderr(std::process::Stdio::null())
758            .status();
759    }
760}
761
762#[cfg(test)]
763mod tests;