1use anyhow::Result;
13
14use crate::types::{Intent, ProjectProfile};
15
16pub trait Prompter {
19 fn text(&self, prompt: &str, default: &str) -> Result<String>;
20}
21
22pub struct DialoguerPrompter;
24
25impl Prompter for DialoguerPrompter {
26 fn text(&self, prompt: &str, default: &str) -> Result<String> {
27 let v: String = dialoguer::Input::new()
30 .with_prompt(prompt)
31 .default(default.to_string())
32 .interact_text()?;
33 Ok(v)
34 }
35}
36
37pub fn run(profile: &ProjectProfile, prompter: &dyn Prompter) -> Result<Intent> {
40 let desc_default = profile
41 .description_hint
42 .clone()
43 .unwrap_or_else(|| "No description yet".to_string());
44 let q1 = prompter.text(
45 "What does your tool do? (one sentence; describe the task, not the tool)",
46 &desc_default,
47 )?;
48 let one_line_description = q1.trim().to_string();
49
50 let q2 = prompter.text(
51 "When should an agent use this? (trigger verbs or scenarios, comma- or semicolon-separated)",
52 "",
53 )?;
54 let when_to_use_phrases: Vec<String> = q2
55 .split([',', ';'])
56 .map(|s| s.trim().to_string())
57 .filter(|s| !s.is_empty())
58 .collect();
59
60 let author = prompter.text("Author name (for plugin.json)", "")?;
61 let author = author.trim();
62 let author = if author.is_empty() {
63 None
64 } else {
65 Some(author.to_string())
66 };
67
68 let license = prompter.text("License SPDX id (enter for MIT)", "MIT")?;
69 let license = license.trim();
70 let license = if license.is_empty() || license.eq_ignore_ascii_case("MIT") {
71 Some("MIT".to_string())
72 } else {
73 Some(license.to_string())
74 };
75
76 if profile.has_cli {
77 let suggest = profile.name.clone();
83 let q3 = prompter.text(
84 "What's the exact command an agent should run to use it?",
85 &suggest,
86 )?;
87 let invocation_command = if q3.trim().is_empty() {
88 Some(suggest)
89 } else {
90 Some(q3.trim().to_string())
91 };
92 Ok(Intent {
93 one_line_description,
94 when_to_use_phrases,
95 invocation_command,
96 import_pattern: None,
97 author,
98 license,
99 ..Default::default()
100 })
101 } else {
102 let q3 = prompter.text(
103 "What's the import pattern an agent should use? (e.g. import { foo } from 'yourpkg')",
104 "",
105 )?;
106 Ok(Intent {
107 one_line_description,
108 when_to_use_phrases,
109 invocation_command: None,
110 import_pattern: if q3.trim().is_empty() {
111 None
112 } else {
113 Some(q3.trim().to_string())
114 },
115 author,
116 license,
117 ..Default::default()
118 })
119 }
120}
121
122#[cfg(test)]
123pub mod stub {
124 use super::Prompter;
127 use anyhow::Result;
128 use std::cell::RefCell;
129 use std::collections::VecDeque;
130
131 pub struct StubPrompter {
135 answers: RefCell<VecDeque<String>>,
136 }
137
138 impl StubPrompter {
139 pub fn new(answers: Vec<String>) -> Self {
140 Self {
141 answers: RefCell::new(VecDeque::from(answers)),
142 }
143 }
144 }
145
146 impl Prompter for StubPrompter {
147 fn text(&self, _prompt: &str, default: &str) -> Result<String> {
148 let mut q = self.answers.borrow_mut();
149 Ok(q.pop_front().unwrap_or_else(|| default.to_string()))
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::types::ProjectProfile;
158
159 #[test]
160 fn cli_interview_builds_intent_with_invocation() {
161 let mut profile = ProjectProfile::test_default();
162 profile.has_cli = true;
163 let stub = stub::StubPrompter::new(vec![
165 "serve my notes".to_string(),
166 "write journals, log entries".to_string(),
167 String::new(), String::new(), "chronicle --new 'entry'".to_string(),
170 ]);
171 let intent = run(&profile, &stub).unwrap();
172 assert_eq!(intent.one_line_description, "serve my notes");
173 assert_eq!(
174 intent.when_to_use_phrases,
175 vec!["write journals".to_string(), "log entries".to_string()]
176 );
177 assert_eq!(
178 intent.invocation_command.as_deref(),
179 Some("chronicle --new 'entry'")
180 );
181 assert!(intent.import_pattern.is_none());
182 assert_eq!(intent.license.as_deref(), Some("MIT"));
183 }
184
185 #[test]
186 fn pure_library_interview_builds_intent_with_import() {
187 let mut profile = ProjectProfile::test_default();
188 profile.has_cli = false;
189 let stub = stub::StubPrompter::new(vec![
190 "parse CSVs".to_string(),
191 "ingest, convert".to_string(),
192 "Jane".to_string(),
193 "Apache-2.0".to_string(),
194 "import { parse } from 'fastcsv'".to_string(),
195 ]);
196 let intent = run(&profile, &stub).unwrap();
197 assert!(intent.invocation_command.is_none());
198 assert_eq!(
199 intent.import_pattern.as_deref(),
200 Some("import { parse } from 'fastcsv'")
201 );
202 assert_eq!(intent.author.as_deref(), Some("Jane"));
203 assert_eq!(intent.license.as_deref(), Some("Apache-2.0"));
204 }
205}