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