Skip to main content

caboodle/
interview.rs

1use std::{
2    fs,
3    io::{BufRead, Write},
4    path::Path,
5};
6
7use anyhow::{bail, Context, Result};
8use serde::{Deserialize, Serialize};
9
10use crate::model::{
11    CrewMemberTheme, CrewMode, InstallIntent, Plan, Profile, QuestionContract, SCHEMA_VERSION,
12};
13
14#[derive(Debug, Default, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct Draft {
17    schema_version: u32,
18    profile: Option<Profile>,
19    crew: Option<CrewMode>,
20    intended_use: Option<String>,
21    crew_count: Option<usize>,
22    #[serde(default)]
23    crew_members: Vec<CrewMemberTheme>,
24    member_name: Option<String>,
25    member_theme: Option<String>,
26    member_domain: Option<String>,
27    question_count: Option<usize>,
28    #[serde(default)]
29    anticipated_questions: Vec<QuestionContract>,
30    question: Option<String>,
31    answer_shape: Option<String>,
32    seed_intent: Option<String>,
33    sparql: Option<String>,
34}
35
36impl Draft {
37    fn read(path: &Path) -> Result<Self> {
38        if !path.exists() {
39            return Ok(Self {
40                schema_version: SCHEMA_VERSION,
41                ..Self::default()
42            });
43        }
44        let body = fs::read_to_string(path)
45            .with_context(|| format!("read interview session {}", path.display()))?;
46        let draft: Self = toml::from_str(&body)
47            .with_context(|| format!("parse interview session {}", path.display()))?;
48        if draft.schema_version != SCHEMA_VERSION {
49            bail!(
50                "unsupported interview schema {}; expected {}",
51                draft.schema_version,
52                SCHEMA_VERSION
53            );
54        }
55        Ok(draft)
56    }
57
58    fn write(&self, path: &Path) -> Result<()> {
59        let parent = path.parent().unwrap_or_else(|| Path::new("."));
60        fs::create_dir_all(parent)
61            .with_context(|| format!("create interview directory {}", parent.display()))?;
62        let mut tmp = tempfile::NamedTempFile::new_in(parent)
63            .with_context(|| format!("create temporary interview in {}", parent.display()))?;
64        tmp.write_all(toml::to_string_pretty(self)?.as_bytes())
65            .context("serialize interview session")?;
66        tmp.persist(path)
67            .map_err(|error| error.error)
68            .with_context(|| format!("replace interview session {}", path.display()))?;
69        Ok(())
70    }
71}
72
73pub fn guided<R: BufRead, W: Write>(
74    input: &mut R,
75    output: &mut W,
76    session_path: &Path,
77    plan_path: &Path,
78) -> Result<Plan> {
79    let mut draft = Draft::read(session_path)?;
80    draft.write(session_path)?;
81
82    if draft.profile.is_none() {
83        let answer = ask(
84            input,
85            output,
86            "profile [kg/retrieval/code-intel/crew/everything]: ",
87            session_path,
88        )?;
89        draft.profile = Some(parse_profile(&answer)?);
90        draft.write(session_path)?;
91    } else {
92        writeln!(output, "resuming: profile already answered")?;
93    }
94
95    let profile = draft
96        .profile
97        .context("interview profile was not recorded")?;
98    if profile == Profile::Crew && draft.crew.is_none() {
99        let answer = ask(
100            input,
101            output,
102            "crew [shantytown/creel/both/standalone]: ",
103            session_path,
104        )?;
105        draft.crew = Some(parse_crew(&answer)?);
106        draft.write(session_path)?;
107    }
108
109    if draft.intended_use.is_none() {
110        draft.intended_use = Some(ask(
111            input,
112            output,
113            "what should this installation help you do? ",
114            session_path,
115        )?);
116        draft.write(session_path)?;
117    }
118    if draft.crew_count.is_none() {
119        draft.crew_count = Some(parse_count(
120            &ask(
121                input,
122                output,
123                "how many themed crew members should be configured? ",
124                session_path,
125            )?,
126            "crew member",
127        )?);
128        draft.write(session_path)?;
129    }
130    while draft.crew_members.len() < draft.crew_count.unwrap_or(0) {
131        let ordinal = draft.crew_members.len() + 1;
132        if draft.member_name.is_none() {
133            draft.member_name = Some(ask(
134                input,
135                output,
136                &format!("crew member {ordinal} name: "),
137                session_path,
138            )?);
139            draft.write(session_path)?;
140        }
141        if draft.member_theme.is_none() {
142            draft.member_theme = Some(ask(
143                input,
144                output,
145                &format!("crew member {ordinal} theme or identity: "),
146                session_path,
147            )?);
148            draft.write(session_path)?;
149        }
150        if draft.member_domain.is_none() {
151            draft.member_domain = Some(ask(
152                input,
153                output,
154                &format!("crew member {ordinal} knowledge domain: "),
155                session_path,
156            )?);
157            draft.write(session_path)?;
158        }
159        let role = ask(
160            input,
161            output,
162            &format!("crew member {ordinal} role and responsibilities: "),
163            session_path,
164        )?;
165        draft.crew_members.push(CrewMemberTheme {
166            name: draft.member_name.take().unwrap(),
167            theme: draft.member_theme.take().unwrap(),
168            domain: draft.member_domain.take().unwrap(),
169            role,
170        });
171        draft.write(session_path)?;
172    }
173    if draft.question_count.is_none() {
174        let answer = ask(
175            input,
176            output,
177            "how many ontology questions must the finished graph answer? \
178             (press enter to use the built-in self-test question) ",
179            session_path,
180        )?;
181        if answer.is_empty() {
182            // A first-time user has no ontology yet and cannot write SPARQL for
183            // one (aegis-ro425e.4). Offer the self-test contract instead of refusing.
184            writeln!(
185                output,
186                "using the built-in self-test question; add your own later by editing the plan's [intent]"
187            )?;
188            draft.question_count = Some(1);
189            draft
190                .anticipated_questions
191                .push(QuestionContract::self_test());
192        } else {
193            draft.question_count = Some(parse_count(&answer, "anticipated question")?);
194            if draft.question_count == Some(0) {
195                bail!("at least one anticipated ontology question is required");
196            }
197        }
198        draft.write(session_path)?;
199    }
200    while draft.anticipated_questions.len() < draft.question_count.unwrap_or(0) {
201        let ordinal = draft.anticipated_questions.len() + 1;
202        if draft.question.is_none() {
203            draft.question = Some(ask(
204                input,
205                output,
206                &format!("anticipated question {ordinal}: "),
207                session_path,
208            )?);
209            draft.write(session_path)?;
210        }
211        if draft.answer_shape.is_none() {
212            draft.answer_shape = Some(ask(
213                input,
214                output,
215                &format!("question {ordinal} expected answer shape: "),
216                session_path,
217            )?);
218            draft.write(session_path)?;
219        }
220        if draft.seed_intent.is_none() {
221            draft.seed_intent = Some(ask(
222                input,
223                output,
224                &format!("question {ordinal} fixture or seed facts: "),
225                session_path,
226            )?);
227            draft.write(session_path)?;
228        }
229        if draft.sparql.is_none() {
230            draft.sparql = Some(ask(
231                input,
232                output,
233                &format!("question {ordinal} executable SELECT/ASK SPARQL: "),
234                session_path,
235            )?);
236            draft.write(session_path)?;
237        }
238        let expected = ask(
239            input,
240            output,
241            &format!("question {ordinal} expected result marker: "),
242            session_path,
243        )?;
244        draft.anticipated_questions.push(QuestionContract {
245            question: draft.question.take().unwrap(),
246            answer_shape: draft.answer_shape.take().unwrap(),
247            seed_intent: draft.seed_intent.take().unwrap(),
248            sparql: draft.sparql.take().unwrap(),
249            expected,
250            self_test: false,
251        });
252        draft.write(session_path)?;
253    }
254
255    let mut plan = if profile == Profile::Crew {
256        Plan::for_crew(draft.crew.context("crew profile requires a crew answer")?)
257    } else {
258        Plan::for_profile(profile)
259    };
260    plan.intent = Some(InstallIntent {
261        intended_use: draft.intended_use.clone().unwrap(),
262        crew_members: draft.crew_members.clone(),
263        anticipated_questions: draft.anticipated_questions.clone(),
264    });
265    plan.write(plan_path)?;
266    fs::remove_file(session_path)
267        .with_context(|| format!("remove completed interview {}", session_path.display()))?;
268    writeln!(output, "plan: {}", plan_path.display())?;
269    Ok(plan)
270}
271
272fn ask<R: BufRead, W: Write>(
273    input: &mut R,
274    output: &mut W,
275    prompt: &str,
276    session_path: &Path,
277) -> Result<String> {
278    write!(output, "{prompt}")?;
279    output.flush()?;
280    let mut answer = String::new();
281    if input.read_line(&mut answer)? == 0 {
282        bail!(
283            "interview paused at end of input; resume with --session {}",
284            session_path.display()
285        );
286    }
287    Ok(answer.trim().to_owned())
288}
289
290fn parse_profile(answer: &str) -> Result<Profile> {
291    match answer.to_ascii_lowercase().as_str() {
292        "kg" => Ok(Profile::Kg),
293        "retrieval" => Ok(Profile::Retrieval),
294        "code-intel" => Ok(Profile::CodeIntel),
295        "crew" => Ok(Profile::Crew),
296        "everything" => Ok(Profile::Everything),
297        _ => bail!(
298            "invalid profile {answer:?}; expected kg, retrieval, code-intel, crew, or everything"
299        ),
300    }
301}
302
303fn parse_count(answer: &str, noun: &str) -> Result<usize> {
304    answer
305        .parse()
306        .with_context(|| format!("{noun} count must be a non-negative integer"))
307}
308
309fn parse_crew(answer: &str) -> Result<CrewMode> {
310    match answer.to_ascii_lowercase().as_str() {
311        "shantytown" => Ok(CrewMode::Shantytown),
312        "creel" => Ok(CrewMode::Creel),
313        "both" => Ok(CrewMode::Both),
314        "standalone" => Ok(CrewMode::Standalone),
315        _ => bail!("invalid crew mode {answer:?}; expected shantytown, creel, both, or standalone"),
316    }
317}