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 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 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;