1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{bail, Context, Result};
8use serde::{Deserialize, Serialize};
9
10pub const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum Profile {
15 Kg,
16 Retrieval,
17 CodeIntel,
18 Crew,
19 Everything,
20}
21
22impl Profile {
23 pub fn tools(self) -> Vec<ToolName> {
26 let mut tools = self.builtin_tools();
27 tools.extend(self.members());
28 tools
29 }
30
31 pub fn accepts(self, tools: &[ToolName]) -> bool {
37 let builtins = self.builtin_tools();
38 let members = self.members();
39 let (head, tail) = tools.split_at(tools.len().min(builtins.len()));
40 let mut allowed = members.iter();
41 head == builtins.as_slice() && tail.iter().all(|t| allowed.any(|m| m == t))
42 }
43
44 pub fn members(self) -> Vec<ToolName> {
46 crate::members::all()
47 .iter()
48 .filter(|m| m.profiles.contains(&self))
49 .map(|m| ToolName::Member(m.name.as_str()))
50 .collect()
51 }
52
53 pub fn builtin_tools(self) -> Vec<ToolName> {
55 match self {
56 Self::Kg => vec![ToolName::Quipu, ToolName::Camayoc],
57 Self::Retrieval => vec![ToolName::Quipu, ToolName::Camayoc, ToolName::Bobbin],
58 Self::CodeIntel => vec![
59 ToolName::Quipu,
60 ToolName::Camayoc,
61 ToolName::Bobbin,
62 ToolName::Yupana,
63 ],
64 Self::Crew => vec![ToolName::Quipu, ToolName::Camayoc, ToolName::Bobbin],
65 Self::Everything => vec![
66 ToolName::Quipu,
67 ToolName::Camayoc,
68 ToolName::Bobbin,
69 ToolName::Yupana,
70 ToolName::DesirePath,
71 ],
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum CrewMode {
79 Shantytown,
80 Creel,
81 Both,
82 Standalone,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum CrewOwner {
88 Shantytown,
89 Creel,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum CrewRouting {
95 SingleOwner,
96 ExplicitHandoff,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct CrewSelection {
102 pub mode: CrewMode,
103 pub durable_owner: Option<CrewOwner>,
104 pub burst_owner: Option<CrewOwner>,
105 pub routing: Option<CrewRouting>,
106 pub policy: CrewPolicy,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct CrewPolicy {
112 pub identity_source: IdentitySource,
113 pub model: Option<String>,
114 pub tools: Vec<ToolName>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum IdentitySource {
120 Quipu,
121}
122
123impl CrewPolicy {
124 fn standard() -> Self {
125 Self {
126 identity_source: IdentitySource::Quipu,
127 model: None,
128 tools: Profile::Crew.tools(),
129 }
130 }
131
132 fn validate(&self) -> Result<()> {
133 if !Profile::Crew.accepts(&self.tools) {
134 bail!("crew policy tools must match the crew profile conventions");
135 }
136 if self
137 .model
138 .as_ref()
139 .is_some_and(|model| model.trim().is_empty())
140 {
141 bail!("crew policy model must be omitted or non-empty");
142 }
143 Ok(())
144 }
145}
146
147impl CrewSelection {
148 pub fn for_mode(mode: CrewMode) -> Self {
149 match mode {
150 CrewMode::Shantytown => Self {
151 mode,
152 durable_owner: Some(CrewOwner::Shantytown),
153 burst_owner: None,
154 routing: Some(CrewRouting::SingleOwner),
155 policy: CrewPolicy::standard(),
156 },
157 CrewMode::Creel => Self {
158 mode,
159 durable_owner: None,
160 burst_owner: Some(CrewOwner::Creel),
161 routing: Some(CrewRouting::SingleOwner),
162 policy: CrewPolicy::standard(),
163 },
164 CrewMode::Both => Self {
165 mode,
166 durable_owner: Some(CrewOwner::Shantytown),
167 burst_owner: Some(CrewOwner::Creel),
168 routing: Some(CrewRouting::ExplicitHandoff),
169 policy: CrewPolicy::standard(),
170 },
171 CrewMode::Standalone => Self {
172 mode,
173 durable_owner: None,
174 burst_owner: None,
175 routing: None,
176 policy: CrewPolicy::standard(),
177 },
178 }
179 }
180
181 fn validate(&self) -> Result<()> {
182 let mut expected = Self::for_mode(self.mode);
183 expected.policy = self.policy.clone();
184 if self != &expected {
185 bail!(
186 "crew {:?} must use the declared ownership/routing contract; expected {:?}",
187 self.mode,
188 expected
189 );
190 }
191 self.policy.validate()?;
192 Ok(())
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
197pub enum ToolName {
198 Quipu,
199 Camayoc,
200 Bobbin,
201 Yupana,
202 DesirePath,
203 Member(&'static str),
207}
208
209impl ToolName {
210 pub const BUILTINS: [ToolName; 5] = [
214 Self::Quipu,
215 Self::Camayoc,
216 Self::Bobbin,
217 Self::Yupana,
218 Self::DesirePath,
219 ];
220
221 pub fn as_str(self) -> &'static str {
222 match self {
223 Self::Quipu => "quipu",
224 Self::Camayoc => "camayoc",
225 Self::Bobbin => "bobbin",
226 Self::Yupana => "yupana",
227 Self::DesirePath => "desire-path",
228 Self::Member(name) => name,
229 }
230 }
231
232 pub fn parse(name: &str) -> Option<Self> {
234 if let Some(builtin) = Self::BUILTINS.into_iter().find(|t| t.as_str() == name) {
235 return Some(builtin);
236 }
237 Some(Self::Member(crate::members::get(name)?.name.as_str()))
238 }
239}
240
241impl Serialize for ToolName {
242 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
243 s.serialize_str(self.as_str())
244 }
245}
246
247impl<'de> Deserialize<'de> for ToolName {
248 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
249 let name = String::deserialize(d)?;
250 Self::parse(&name).ok_or_else(|| {
251 serde::de::Error::custom(format!(
252 "unknown tool '{name}': neither a built-in tool nor a stack member of this \
253 caboodle build (a member removed since the plan was written, or a plan from \
254 a newer caboodle); re-plan or use the caboodle that wrote it"
255 ))
256 })
257 }
258}
259
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "kebab-case")]
262pub enum QuipuFlavor {
263 #[default]
267 Release,
268 Lancedb,
272}
273
274impl QuipuFlavor {
275 fn is_release(&self) -> bool {
279 *self == Self::Release
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct Plan {
286 pub schema_version: u32,
287 pub profile: Profile,
288 pub tools: Vec<ToolName>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub shares: Vec<PathBuf>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub quipu_db: Option<PathBuf>,
293 #[serde(default, skip_serializing_if = "QuipuFlavor::is_release")]
294 pub quipu_flavor: QuipuFlavor,
295 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
297 pub external_quipu: bool,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub stack_config: Option<StackConfig>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub crew: Option<CrewSelection>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub intent: Option<InstallIntent>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub embedding_model: Option<crate::embedding::EmbeddingModel>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub quipu_mcp: Option<QuipuMcp>,
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub host_profiles: Vec<HostProfile>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct HostProfile {
322 pub name: String,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub crew_excluded_owner: Option<String>,
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub excluded_tools: Vec<ExcludedTool>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct ExcludedTool {
337 pub tool: ToolName,
338 pub owner: String,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(deny_unknown_fields)]
343pub struct QuipuMcp {
344 pub url: String,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub token_file: Option<String>,
351}
352
353impl QuipuMcp {
354 fn validate(&self) -> Result<()> {
355 require_safe_text("quipu_mcp.url", &self.url)?;
356 let rest = self
357 .url
358 .strip_prefix("https://")
359 .or_else(|| self.url.strip_prefix("http://"))
360 .ok_or_else(|| anyhow::anyhow!("quipu_mcp.url must be an http:// or https:// URL"))?;
361 let authority = rest.split('/').next().unwrap_or("");
362 if authority.is_empty() || authority.contains('@') || self.url.contains(['?', '#', ' ']) {
363 bail!(
364 "quipu_mcp.url must be a plain server URL with no credentials, query or fragment"
365 );
366 }
367 if let Some(path) = &self.token_file {
368 require_safe_text("quipu_mcp.token_file", path)?;
369 if !path.starts_with('/') || path.contains(['\'', '\n', '\r']) {
370 bail!(
371 "quipu_mcp.token_file must be an absolute path without quotes or line breaks"
372 );
373 }
374 }
375 Ok(())
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(deny_unknown_fields)]
381pub struct StackConfig {
382 pub quipu: QuipuStackConfig,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(deny_unknown_fields)]
387pub struct QuipuStackConfig {
388 pub owl: QuipuOwlConfig,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct QuipuOwlConfig {
394 pub reactive_materialize: bool,
395}
396
397impl StackConfig {
398 pub fn recommended() -> Self {
399 Self {
400 quipu: QuipuStackConfig {
401 owl: QuipuOwlConfig {
402 reactive_materialize: true,
403 },
404 },
405 }
406 }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct InstallIntent {
412 pub intended_use: String,
413 #[serde(default)]
414 pub crew_members: Vec<CrewMemberTheme>,
415 pub anticipated_questions: Vec<QuestionContract>,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct CrewMemberTheme {
421 pub name: String,
422 pub theme: String,
423 pub domain: String,
424 pub role: String,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct QuestionContract {
430 pub question: String,
431 pub answer_shape: String,
432 pub seed_intent: String,
433 pub sparql: String,
434 pub expected: String,
435 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
439 pub self_test: bool,
440}
441
442pub const SELF_TEST_MARKER: &str = "caboodle-verify-roundtrip";
446
447impl QuestionContract {
448 pub fn self_test() -> Self {
453 Self {
454 question: "does the knowledge store answer a query about a fact written to it?".to_owned(),
455 answer_shape: "one entity label".to_owned(),
456 seed_intent: format!(
457 "caboodle writes one Verification node named {SELF_TEST_MARKER} into a scratch store"
458 ),
459 sparql: format!(
460 "SELECT ?s ?label WHERE {{ ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label . FILTER(?label = \"{SELF_TEST_MARKER}\") }}"
461 ),
462 expected: SELF_TEST_MARKER.to_owned(),
463 self_test: true,
464 }
465 }
466
467 pub fn is_self_test(&self) -> bool {
472 self.self_test
473 || *self
474 == Self {
475 self_test: false,
476 ..Self::self_test()
477 }
478 }
479}
480
481impl InstallIntent {
482 pub fn read(path: &Path) -> Result<Self> {
483 if !path.exists() {
484 bail!(
485 "no intent file at {}; run `tambo init --guided` to answer the interview instead, or write one (see the \"Using caboodle\" chapter of docs/book)",
486 path.display()
487 );
488 }
489 let body =
490 fs::read_to_string(path).with_context(|| format!("read intent {}", path.display()))?;
491 let intent: Self =
492 toml::from_str(&body).with_context(|| format!("parse intent {}", path.display()))?;
493 intent.validate()?;
494 Ok(intent)
495 }
496
497 pub fn validate(&self) -> Result<()> {
498 require_safe_text("intended use", &self.intended_use)?;
499 if self.anticipated_questions.is_empty() {
500 bail!("intent requires at least one anticipated ontology question");
501 }
502 let mut names = std::collections::BTreeSet::new();
503 for member in &self.crew_members {
504 for (field, value) in [
505 ("crew member name", &member.name),
506 ("crew member theme", &member.theme),
507 ("crew member domain", &member.domain),
508 ("crew member role", &member.role),
509 ] {
510 require_safe_text(field, value)?;
511 }
512 if !names.insert(member.name.to_ascii_lowercase()) {
513 bail!("duplicate crew member name {:?}", member.name);
514 }
515 }
516 let mut questions = std::collections::BTreeSet::new();
517 for contract in &self.anticipated_questions {
518 for (field, value) in [
519 ("anticipated question", &contract.question),
520 ("answer shape", &contract.answer_shape),
521 ("seed intent", &contract.seed_intent),
522 ("expected answer", &contract.expected),
523 ] {
524 require_safe_text(field, value)?;
525 }
526 if !questions.insert(contract.question.to_ascii_lowercase()) {
527 bail!("duplicate anticipated question {:?}", contract.question);
528 }
529 let query = contract.sparql.to_ascii_uppercase();
530 if !(query.split_whitespace().any(|word| word == "SELECT")
531 || query.split_whitespace().any(|word| word == "ASK"))
532 {
533 bail!("anticipated question SPARQL must contain SELECT or ASK");
534 }
535 require_safe_text("anticipated question SPARQL", &contract.sparql)?;
536 }
537 Ok(())
538 }
539}
540
541fn require_safe_text(field: &str, value: &str) -> Result<()> {
542 if value.trim().is_empty() {
543 bail!("{field} must not be empty");
544 }
545 let lower = value.to_ascii_lowercase();
546 for marker in [
547 "password=",
548 "token=",
549 "secret=",
550 "authorization:",
551 "bearer ",
552 ] {
553 if lower.contains(marker) {
554 bail!("{field} appears to contain a credential ({marker})");
555 }
556 }
557 Ok(())
558}
559
560impl Plan {
561 pub fn requires_remote_quipu(&self) -> bool {
564 self.quipu_mcp.is_some()
565 || std::env::var("QUIPU_SERVER").is_ok_and(|url| !url.trim().is_empty())
566 }
567
568 pub fn for_profile(profile: Profile) -> Self {
569 Self {
570 schema_version: SCHEMA_VERSION,
571 profile,
572 tools: profile.tools(),
573 shares: Vec::new(),
574 quipu_db: None,
575 quipu_flavor: QuipuFlavor::default(),
576 external_quipu: false,
577 stack_config: Some(StackConfig::recommended()),
578 crew: None,
579 intent: None,
580 embedding_model: None,
581 quipu_mcp: None,
582 host_profiles: Vec::new(),
583 }
584 }
585
586 pub fn for_crew(mode: CrewMode) -> Self {
587 Self {
588 profile: Profile::Crew,
589 crew: Some(CrewSelection::for_mode(mode)),
590 ..Self::for_profile(Profile::Crew)
591 }
592 }
593
594 pub fn read(path: &Path) -> Result<Self> {
595 if !path.exists() {
596 bail!(
597 "no plan at {}; run `tambo init --guided` (or `tambo plan`) first",
598 path.display()
599 );
600 }
601 let body =
602 fs::read_to_string(path).with_context(|| format!("read plan {}", path.display()))?;
603 let plan: Self =
604 toml::from_str(&body).with_context(|| format!("parse plan {}", path.display()))?;
605 plan.validate()?;
606 Ok(plan)
607 }
608
609 pub fn write(&self, path: &Path) -> Result<()> {
610 self.validate()?;
611 let body = toml::to_string_pretty(self).context("serialize plan")?;
612 fs::write(path, body).with_context(|| format!("write plan {}", path.display()))
613 }
614
615 pub fn validate(&self) -> Result<()> {
616 if self.schema_version != SCHEMA_VERSION {
617 bail!(
618 "unsupported plan schema {}; expected {}",
619 self.schema_version,
620 SCHEMA_VERSION
621 );
622 }
623 if self.external_quipu && self.quipu_flavor != QuipuFlavor::Release {
624 bail!("external_quipu cannot request a Tambo build flavor");
625 }
626 if self.tools.is_empty() {
627 bail!("plan selects no tools");
628 }
629 if !self.profile.accepts(&self.tools) {
630 bail!(
631 "tools do not match the {:?} profile conventions",
632 self.profile
633 );
634 }
635 if !self.shares.is_empty() && self.quipu_db.is_none() {
636 bail!("plans with knowledge shares require an explicit --quipu-db");
637 }
638 if self.shares.iter().any(|path| path.as_os_str().is_empty()) {
639 bail!("knowledge share paths must not be empty");
640 }
641 if let Some(intent) = &self.intent {
642 intent.validate()?;
643 }
644 if let Some(model) = &self.embedding_model {
645 model.validate()?;
646 }
647 if let Some(mcp) = &self.quipu_mcp {
648 if !self.tools.contains(&ToolName::Quipu) {
649 bail!("[quipu_mcp] requires a profile that installs quipu");
650 }
651 mcp.validate()?;
652 }
653 match (self.profile, &self.crew) {
654 (Profile::Crew, Some(crew)) => crew.validate()?,
655 (Profile::Crew, None) => bail!("crew profile requires a [crew] selection"),
656 (Profile::Everything, Some(crew)) => crew.validate()?,
657 (_, Some(_)) => bail!("[crew] is only valid with profile = \"crew\" or \"everything\""),
658 (_, None) => {}
659 }
660 let mut names = std::collections::HashSet::new();
661 for host in &self.host_profiles {
662 if host.name.trim().is_empty() || !names.insert(host.name.as_str()) {
663 bail!("host profile names must be non-empty and unique");
664 }
665 if host
666 .crew_excluded_owner
667 .as_deref()
668 .is_some_and(|owner| owner.trim().is_empty())
669 {
670 bail!(
671 "host profile {}: crew_excluded_owner must name the owning lane",
672 host.name
673 );
674 }
675 let mut excluded = std::collections::BTreeSet::new();
676 for entry in &host.excluded_tools {
677 if entry.owner.trim().is_empty() {
678 bail!(
679 "host profile {}: excluded tool {} must name the owning lane",
680 host.name,
681 entry.tool.as_str()
682 );
683 }
684 if !matches!(entry.tool, ToolName::Member(_))
687 || !self.tools.contains(&entry.tool)
688 || !excluded.insert(entry.tool)
689 {
690 bail!(
691 "host profile {}: excluded tool {} must be a selected stack member, listed once",
692 host.name,
693 entry.tool.as_str()
694 );
695 }
696 }
697 }
698 Ok(())
699 }
700
701 pub fn for_host(mut self, host: Option<&str>) -> Result<(Self, Vec<String>)> {
704 let Some(host) = host else {
705 return Ok((self, Vec::new()));
706 };
707 let profile = self
708 .host_profiles
709 .iter()
710 .find(|p| p.name == host)
711 .cloned()
712 .with_context(|| format!("plan has no host profile named {host}"))?;
713 let mut excluded = Vec::new();
714 if let Some(owner) = profile.crew_excluded_owner {
715 if self.profile == Profile::Crew {
716 bail!("host profile {host} excludes crew, but this plan's profile IS crew");
717 }
718 if self.crew.take().is_some() {
719 excluded.push(format!(
720 "crew: EXCLUDED on host profile {host}; owned by {owner}; not installed or verified by caboodle"
721 ));
722 }
723 }
724 if let Some(applied) = self.host_profiles.iter_mut().find(|p| p.name == host) {
727 applied.excluded_tools.clear();
728 }
729 for entry in &profile.excluded_tools {
730 if let Some(index) = self.tools.iter().position(|t| *t == entry.tool) {
731 self.tools.remove(index);
732 excluded.push(format!(
733 "{}: EXCLUDED on host profile {host}; owned by {}; not installed or verified by caboodle",
734 entry.tool.as_str(),
735 entry.owner
736 ));
737 }
738 }
739 Ok((self, excluded))
740 }
741}
742
743#[derive(Debug, Default, Serialize, Deserialize)]
744pub struct State {
745 pub schema_version: u32,
746 pub tools: BTreeMap<String, ToolState>,
747 #[serde(default)]
748 pub crew: BTreeMap<String, CrewRuntimeState>,
749 #[serde(default)]
750 pub shares: BTreeMap<String, ShareState>,
751 #[serde(default)]
752 pub models: BTreeMap<String, ModelArtifactState>,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct ToolState {
757 pub version: String,
758 pub applied: bool,
759 pub verified: bool,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
763pub struct CrewRuntimeState {
764 pub version: String,
765 pub applied: bool,
766 pub verified: bool,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct ModelArtifactState {
771 pub path: PathBuf,
772 pub sha256: String,
773 pub provisioned: bool,
774 pub verified: bool,
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
780pub struct ShareState {
781 pub path: PathBuf,
782 pub staging_graph: String,
783 pub outcome: String,
784 pub promotion_eligible: bool,
785 pub blockers: Vec<String>,
786}
787
788impl State {
789 pub fn read(path: &Path) -> Result<Self> {
790 if !path.exists() {
791 return Ok(Self {
792 schema_version: SCHEMA_VERSION,
793 tools: BTreeMap::new(),
794 crew: BTreeMap::new(),
795 shares: BTreeMap::new(),
796 models: BTreeMap::new(),
797 });
798 }
799 let body =
800 fs::read_to_string(path).with_context(|| format!("read state {}", path.display()))?;
801 let state: Self = serde_json::from_str(&body)
802 .with_context(|| format!("parse state {}", path.display()))?;
803 if state.schema_version != SCHEMA_VERSION {
804 bail!("unsupported state schema {}", state.schema_version);
805 }
806 Ok(state)
807 }
808
809 pub fn write(&self, path: &Path) -> Result<()> {
810 let parent = path.parent().unwrap_or_else(|| Path::new("."));
811 fs::create_dir_all(parent)
812 .with_context(|| format!("create state directory {}", parent.display()))?;
813 let mut tmp = tempfile::NamedTempFile::new_in(parent)
814 .with_context(|| format!("create temporary state in {}", parent.display()))?;
815 serde_json::to_writer_pretty(&mut tmp, self).context("serialize state")?;
816 use std::io::Write;
817 writeln!(tmp).context("finish state")?;
818 tmp.persist(path)
819 .map_err(|error| error.error)
820 .with_context(|| format!("replace state {}", path.display()))?;
821 Ok(())
822 }
823}
824
825#[cfg(test)]
826mod self_test_question_tests {
827 use super::QuestionContract;
828
829 #[test]
830 fn self_test_is_the_flag_never_the_marker() {
831 assert!(QuestionContract::self_test().is_self_test());
832 let mut users_own = QuestionContract::self_test();
835 users_own.self_test = false;
836 users_own.question = "what depends on the service?".to_owned();
837 assert!(!users_own.is_self_test());
838 let legacy = QuestionContract {
840 self_test: false,
841 ..QuestionContract::self_test()
842 };
843 assert!(legacy.is_self_test());
844 }
845
846 #[test]
847 fn the_flag_round_trips_and_is_omitted_for_user_questions() {
848 let toml = toml::to_string(&QuestionContract::self_test()).unwrap();
849 assert!(toml.contains("self_test = true"));
850 let mut user = QuestionContract::self_test();
851 user.self_test = false;
852 user.question = "q".to_owned();
853 assert!(!toml::to_string(&user).unwrap().contains("self_test"));
854 }
855}
856
857#[cfg(test)]
858mod host_profile_tests {
859 use super::{CrewMode, ExcludedTool, HostProfile, Plan, Profile, ToolName};
860
861 fn plan() -> Plan {
862 let mut plan = Plan::for_profile(Profile::Everything);
863 plan.crew = Plan::for_crew(CrewMode::Both).crew;
864 plan.host_profiles = vec![HostProfile {
865 name: "mac".into(),
866 crew_excluded_owner: Some("st-peer-autopull".into()),
867 excluded_tools: Vec::new(),
868 }];
869 plan
870 }
871
872 #[test]
873 fn host_profile_drops_an_excluded_tool_and_names_its_owner() {
874 let mut excluding = plan();
876 let tool = *excluding
877 .tools
878 .iter()
879 .find(|t| matches!(t, ToolName::Member(_)))
880 .expect("the everything profile carries stack members");
881 excluding.host_profiles[0].excluded_tools = vec![ExcludedTool {
882 tool,
883 owner: "its own release lane".into(),
884 }];
885 excluding.validate().unwrap();
886 let (selected, lines) = excluding.clone().for_host(Some("mac")).unwrap();
887 assert!(!selected.tools.contains(&tool));
888 assert!(lines.iter().any(|l| l.starts_with(tool.as_str())
889 && l.contains("EXCLUDED")
890 && l.contains("its own release lane")));
891 selected.validate().unwrap();
893 let (unchanged, _) = excluding.for_host(None).unwrap();
894 assert!(unchanged.tools.contains(&tool));
895 }
896
897 #[test]
898 fn excluded_tools_must_be_owned_selected_and_listed_once() {
899 let mut ownerless = plan();
900 let tool = *ownerless
901 .tools
902 .iter()
903 .find(|t| matches!(t, ToolName::Member(_)))
904 .unwrap();
905 ownerless.host_profiles[0].excluded_tools = vec![ExcludedTool {
906 tool,
907 owner: " ".into(),
908 }];
909 assert!(ownerless.validate().is_err());
910 let mut twice = plan();
911 let entry = ExcludedTool {
912 tool,
913 owner: "lane".into(),
914 };
915 twice.host_profiles[0].excluded_tools = vec![entry.clone(), entry.clone()];
916 assert!(twice.validate().is_err());
917 let mut unselected = plan();
918 unselected.tools.retain(|t| *t != tool);
919 unselected.host_profiles[0].excluded_tools = vec![ExcludedTool {
920 tool,
921 owner: "lane".into(),
922 }];
923 assert!(unselected.validate().is_err());
924 let mut builtin = plan();
925 builtin.host_profiles[0].excluded_tools = vec![ExcludedTool {
926 tool: ToolName::Bobbin,
927 owner: "lane".into(),
928 }];
929 assert!(builtin.validate().is_err());
930 }
931
932 #[test]
933 fn host_profile_drops_crew_and_names_the_owner() {
934 let (selected, lines) = plan().for_host(Some("mac")).unwrap();
935 assert!(selected.crew.is_none());
936 assert_eq!(lines.len(), 1);
937 assert!(lines[0].contains("EXCLUDED") && lines[0].contains("st-peer-autopull"));
938 let (unchanged, none) = plan().for_host(None).unwrap();
939 assert!(unchanged.crew.is_some() && none.is_empty());
940 assert!(plan().for_host(Some("nonesuch")).is_err());
941 }
942
943 #[test]
944 fn host_profiles_must_be_named_uniquely_and_own_what_they_exclude() {
945 let mut duplicate = plan();
946 duplicate
947 .host_profiles
948 .push(duplicate.host_profiles[0].clone());
949 assert!(duplicate.validate().is_err());
950 let mut ownerless = plan();
951 ownerless.host_profiles[0].crew_excluded_owner = Some(" ".into());
952 assert!(ownerless.validate().is_err());
953 let mut crew_only = Plan::for_crew(CrewMode::Both);
954 crew_only.host_profiles = plan().host_profiles;
955 assert!(crew_only.for_host(Some("mac")).is_err());
956 }
957}