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 `caboodle 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 for_profile(profile: Profile) -> Self {
562 Self {
563 schema_version: SCHEMA_VERSION,
564 profile,
565 tools: profile.tools(),
566 shares: Vec::new(),
567 quipu_db: None,
568 quipu_flavor: QuipuFlavor::default(),
569 external_quipu: false,
570 stack_config: Some(StackConfig::recommended()),
571 crew: None,
572 intent: None,
573 embedding_model: None,
574 quipu_mcp: None,
575 host_profiles: Vec::new(),
576 }
577 }
578
579 pub fn for_crew(mode: CrewMode) -> Self {
580 Self {
581 profile: Profile::Crew,
582 crew: Some(CrewSelection::for_mode(mode)),
583 ..Self::for_profile(Profile::Crew)
584 }
585 }
586
587 pub fn read(path: &Path) -> Result<Self> {
588 if !path.exists() {
589 bail!(
590 "no plan at {}; run `caboodle init --guided` (or `caboodle plan`) first",
591 path.display()
592 );
593 }
594 let body =
595 fs::read_to_string(path).with_context(|| format!("read plan {}", path.display()))?;
596 let plan: Self =
597 toml::from_str(&body).with_context(|| format!("parse plan {}", path.display()))?;
598 plan.validate()?;
599 Ok(plan)
600 }
601
602 pub fn write(&self, path: &Path) -> Result<()> {
603 self.validate()?;
604 let body = toml::to_string_pretty(self).context("serialize plan")?;
605 fs::write(path, body).with_context(|| format!("write plan {}", path.display()))
606 }
607
608 pub fn validate(&self) -> Result<()> {
609 if self.schema_version != SCHEMA_VERSION {
610 bail!(
611 "unsupported plan schema {}; expected {}",
612 self.schema_version,
613 SCHEMA_VERSION
614 );
615 }
616 if self.external_quipu && self.quipu_flavor != QuipuFlavor::Release {
617 bail!("external_quipu cannot request a Caboodle build flavor");
618 }
619 if self.tools.is_empty() {
620 bail!("plan selects no tools");
621 }
622 if !self.profile.accepts(&self.tools) {
623 bail!(
624 "tools do not match the {:?} profile conventions",
625 self.profile
626 );
627 }
628 if !self.shares.is_empty() && self.quipu_db.is_none() {
629 bail!("plans with knowledge shares require an explicit --quipu-db");
630 }
631 if self.shares.iter().any(|path| path.as_os_str().is_empty()) {
632 bail!("knowledge share paths must not be empty");
633 }
634 if let Some(intent) = &self.intent {
635 intent.validate()?;
636 }
637 if let Some(model) = &self.embedding_model {
638 model.validate()?;
639 }
640 if let Some(mcp) = &self.quipu_mcp {
641 if !self.tools.contains(&ToolName::Quipu) {
642 bail!("[quipu_mcp] requires a profile that installs quipu");
643 }
644 mcp.validate()?;
645 }
646 match (self.profile, &self.crew) {
647 (Profile::Crew, Some(crew)) => crew.validate()?,
648 (Profile::Crew, None) => bail!("crew profile requires a [crew] selection"),
649 (Profile::Everything, Some(crew)) => crew.validate()?,
650 (_, Some(_)) => bail!("[crew] is only valid with profile = \"crew\" or \"everything\""),
651 (_, None) => {}
652 }
653 let mut names = std::collections::HashSet::new();
654 for host in &self.host_profiles {
655 if host.name.trim().is_empty() || !names.insert(host.name.as_str()) {
656 bail!("host profile names must be non-empty and unique");
657 }
658 if host
659 .crew_excluded_owner
660 .as_deref()
661 .is_some_and(|owner| owner.trim().is_empty())
662 {
663 bail!(
664 "host profile {}: crew_excluded_owner must name the owning lane",
665 host.name
666 );
667 }
668 let mut excluded = std::collections::BTreeSet::new();
669 for entry in &host.excluded_tools {
670 if entry.owner.trim().is_empty() {
671 bail!(
672 "host profile {}: excluded tool {} must name the owning lane",
673 host.name,
674 entry.tool.as_str()
675 );
676 }
677 if !matches!(entry.tool, ToolName::Member(_))
680 || !self.tools.contains(&entry.tool)
681 || !excluded.insert(entry.tool)
682 {
683 bail!(
684 "host profile {}: excluded tool {} must be a selected stack member, listed once",
685 host.name,
686 entry.tool.as_str()
687 );
688 }
689 }
690 }
691 Ok(())
692 }
693
694 pub fn for_host(mut self, host: Option<&str>) -> Result<(Self, Vec<String>)> {
697 let Some(host) = host else {
698 return Ok((self, Vec::new()));
699 };
700 let profile = self
701 .host_profiles
702 .iter()
703 .find(|p| p.name == host)
704 .cloned()
705 .with_context(|| format!("plan has no host profile named {host}"))?;
706 let mut excluded = Vec::new();
707 if let Some(owner) = profile.crew_excluded_owner {
708 if self.profile == Profile::Crew {
709 bail!("host profile {host} excludes crew, but this plan's profile IS crew");
710 }
711 if self.crew.take().is_some() {
712 excluded.push(format!(
713 "crew: EXCLUDED on host profile {host}; owned by {owner}; not installed or verified by caboodle"
714 ));
715 }
716 }
717 if let Some(applied) = self.host_profiles.iter_mut().find(|p| p.name == host) {
720 applied.excluded_tools.clear();
721 }
722 for entry in &profile.excluded_tools {
723 if let Some(index) = self.tools.iter().position(|t| *t == entry.tool) {
724 self.tools.remove(index);
725 excluded.push(format!(
726 "{}: EXCLUDED on host profile {host}; owned by {}; not installed or verified by caboodle",
727 entry.tool.as_str(),
728 entry.owner
729 ));
730 }
731 }
732 Ok((self, excluded))
733 }
734}
735
736#[derive(Debug, Default, Serialize, Deserialize)]
737pub struct State {
738 pub schema_version: u32,
739 pub tools: BTreeMap<String, ToolState>,
740 #[serde(default)]
741 pub crew: BTreeMap<String, CrewRuntimeState>,
742 #[serde(default)]
743 pub shares: BTreeMap<String, ShareState>,
744 #[serde(default)]
745 pub models: BTreeMap<String, ModelArtifactState>,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct ToolState {
750 pub version: String,
751 pub applied: bool,
752 pub verified: bool,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct CrewRuntimeState {
757 pub version: String,
758 pub applied: bool,
759 pub verified: bool,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
763pub struct ModelArtifactState {
764 pub path: PathBuf,
765 pub sha256: String,
766 pub provisioned: bool,
767 pub verified: bool,
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct ShareState {
774 pub path: PathBuf,
775 pub staging_graph: String,
776 pub outcome: String,
777 pub promotion_eligible: bool,
778 pub blockers: Vec<String>,
779}
780
781impl State {
782 pub fn read(path: &Path) -> Result<Self> {
783 if !path.exists() {
784 return Ok(Self {
785 schema_version: SCHEMA_VERSION,
786 tools: BTreeMap::new(),
787 crew: BTreeMap::new(),
788 shares: BTreeMap::new(),
789 models: BTreeMap::new(),
790 });
791 }
792 let body =
793 fs::read_to_string(path).with_context(|| format!("read state {}", path.display()))?;
794 let state: Self = serde_json::from_str(&body)
795 .with_context(|| format!("parse state {}", path.display()))?;
796 if state.schema_version != SCHEMA_VERSION {
797 bail!("unsupported state schema {}", state.schema_version);
798 }
799 Ok(state)
800 }
801
802 pub fn write(&self, path: &Path) -> Result<()> {
803 let parent = path.parent().unwrap_or_else(|| Path::new("."));
804 fs::create_dir_all(parent)
805 .with_context(|| format!("create state directory {}", parent.display()))?;
806 let mut tmp = tempfile::NamedTempFile::new_in(parent)
807 .with_context(|| format!("create temporary state in {}", parent.display()))?;
808 serde_json::to_writer_pretty(&mut tmp, self).context("serialize state")?;
809 use std::io::Write;
810 writeln!(tmp).context("finish state")?;
811 tmp.persist(path)
812 .map_err(|error| error.error)
813 .with_context(|| format!("replace state {}", path.display()))?;
814 Ok(())
815 }
816}
817
818#[cfg(test)]
819mod self_test_question_tests {
820 use super::QuestionContract;
821
822 #[test]
823 fn self_test_is_the_flag_never_the_marker() {
824 assert!(QuestionContract::self_test().is_self_test());
825 let mut users_own = QuestionContract::self_test();
828 users_own.self_test = false;
829 users_own.question = "what depends on the service?".to_owned();
830 assert!(!users_own.is_self_test());
831 let legacy = QuestionContract {
833 self_test: false,
834 ..QuestionContract::self_test()
835 };
836 assert!(legacy.is_self_test());
837 }
838
839 #[test]
840 fn the_flag_round_trips_and_is_omitted_for_user_questions() {
841 let toml = toml::to_string(&QuestionContract::self_test()).unwrap();
842 assert!(toml.contains("self_test = true"));
843 let mut user = QuestionContract::self_test();
844 user.self_test = false;
845 user.question = "q".to_owned();
846 assert!(!toml::to_string(&user).unwrap().contains("self_test"));
847 }
848}
849
850#[cfg(test)]
851mod host_profile_tests {
852 use super::{CrewMode, ExcludedTool, HostProfile, Plan, Profile, ToolName};
853
854 fn plan() -> Plan {
855 let mut plan = Plan::for_profile(Profile::Everything);
856 plan.crew = Plan::for_crew(CrewMode::Both).crew;
857 plan.host_profiles = vec![HostProfile {
858 name: "mac".into(),
859 crew_excluded_owner: Some("st-peer-autopull".into()),
860 excluded_tools: Vec::new(),
861 }];
862 plan
863 }
864
865 #[test]
866 fn host_profile_drops_an_excluded_tool_and_names_its_owner() {
867 let mut excluding = plan();
869 let tool = *excluding
870 .tools
871 .iter()
872 .find(|t| matches!(t, ToolName::Member(_)))
873 .expect("the everything profile carries stack members");
874 excluding.host_profiles[0].excluded_tools = vec![ExcludedTool {
875 tool,
876 owner: "its own release lane".into(),
877 }];
878 excluding.validate().unwrap();
879 let (selected, lines) = excluding.clone().for_host(Some("mac")).unwrap();
880 assert!(!selected.tools.contains(&tool));
881 assert!(lines.iter().any(|l| l.starts_with(tool.as_str())
882 && l.contains("EXCLUDED")
883 && l.contains("its own release lane")));
884 selected.validate().unwrap();
886 let (unchanged, _) = excluding.for_host(None).unwrap();
887 assert!(unchanged.tools.contains(&tool));
888 }
889
890 #[test]
891 fn excluded_tools_must_be_owned_selected_and_listed_once() {
892 let mut ownerless = plan();
893 let tool = *ownerless
894 .tools
895 .iter()
896 .find(|t| matches!(t, ToolName::Member(_)))
897 .unwrap();
898 ownerless.host_profiles[0].excluded_tools = vec![ExcludedTool {
899 tool,
900 owner: " ".into(),
901 }];
902 assert!(ownerless.validate().is_err());
903 let mut twice = plan();
904 let entry = ExcludedTool {
905 tool,
906 owner: "lane".into(),
907 };
908 twice.host_profiles[0].excluded_tools = vec![entry.clone(), entry.clone()];
909 assert!(twice.validate().is_err());
910 let mut unselected = plan();
911 unselected.tools.retain(|t| *t != tool);
912 unselected.host_profiles[0].excluded_tools = vec![ExcludedTool {
913 tool,
914 owner: "lane".into(),
915 }];
916 assert!(unselected.validate().is_err());
917 let mut builtin = plan();
918 builtin.host_profiles[0].excluded_tools = vec![ExcludedTool {
919 tool: ToolName::Bobbin,
920 owner: "lane".into(),
921 }];
922 assert!(builtin.validate().is_err());
923 }
924
925 #[test]
926 fn host_profile_drops_crew_and_names_the_owner() {
927 let (selected, lines) = plan().for_host(Some("mac")).unwrap();
928 assert!(selected.crew.is_none());
929 assert_eq!(lines.len(), 1);
930 assert!(lines[0].contains("EXCLUDED") && lines[0].contains("st-peer-autopull"));
931 let (unchanged, none) = plan().for_host(None).unwrap();
932 assert!(unchanged.crew.is_some() && none.is_empty());
933 assert!(plan().for_host(Some("nonesuch")).is_err());
934 }
935
936 #[test]
937 fn host_profiles_must_be_named_uniquely_and_own_what_they_exclude() {
938 let mut duplicate = plan();
939 duplicate
940 .host_profiles
941 .push(duplicate.host_profiles[0].clone());
942 assert!(duplicate.validate().is_err());
943 let mut ownerless = plan();
944 ownerless.host_profiles[0].crew_excluded_owner = Some(" ".into());
945 assert!(ownerless.validate().is_err());
946 let mut crew_only = Plan::for_crew(CrewMode::Both);
947 crew_only.host_profiles = plan().host_profiles;
948 assert!(crew_only.for_host(Some("mac")).is_err());
949 }
950}