Skip to main content

skillpack/
interview.rs

1//! The interactive interview. Three questions max (design §5.1 step 2),
2//! producing an [`Intent`]. Bypassable when `skillpack.toml` already holds the
3//! answers.
4//!
5//! For pure-library projects the third question becomes "what's the import
6//! pattern?" instead of "what's the exact command to run?" (design §5.1
7//! "Pure-library path").
8//!
9//! All prompting goes through the [`Prompter`] trait so unit tests can inject a
10//! canned-answer stub instead of driving a real TTY.
11
12use anyhow::Result;
13
14use crate::types::{Intent, ProjectProfile};
15
16/// Abstraction over the prompt backend. The real implementation uses
17/// `dialoguer`; tests inject a deterministic stub.
18pub trait Prompter {
19    fn text(&self, prompt: &str, default: &str) -> Result<String>;
20}
21
22/// A `dialoguer::Input`-backed prompter. Constructed once per interview.
23pub struct DialoguerPrompter;
24
25impl Prompter for DialoguerPrompter {
26    fn text(&self, prompt: &str, default: &str) -> Result<String> {
27        // dialoguer 0.11 builder methods consume `self`; chain them. `theme()`
28        // gives the modern look; `default()` seeds the editable suggestion.
29        let v: String = dialoguer::Input::new()
30            .with_prompt(prompt)
31            .default(default.to_string())
32            .interact_text()?;
33        Ok(v)
34    }
35}
36
37/// Run the interview and return an [`Intent`], seeded from the profile's
38/// introspection hints so the maintainer isn't typing things we already know.
39pub 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        // The suggested default is the bare tool *name* — the thing an agent
78        // would actually type (e.g. `sample-node`), not the internal spawn
79        // argv, whose first element is the runtime (`node`, `go`) or an
80        // absolute build path (design §5.1 Q3). `profile.name` is already the
81        // best-effort human invocation; the maintainer edits from there.
82        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    //! A canned-answer `Prompter` for tests.
125
126    use super::Prompter;
127    use anyhow::Result;
128    use std::cell::RefCell;
129    use std::collections::VecDeque;
130
131    /// Returns the next pre-loaded answer for each `text` call, falling back to
132    /// the supplied default once the queue is empty. De-queues FIFO so the
133    /// answers read in the same order the interview asks them.
134    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        // The interview asks: description, when, author, license, invocation.
164        let stub = stub::StubPrompter::new(vec![
165            "serve my notes".to_string(),
166            "write journals, log entries".to_string(),
167            String::new(), // author
168            String::new(), // license -> MIT
169            "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}