use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Profile {
Kg,
Retrieval,
CodeIntel,
Crew,
Everything,
}
impl Profile {
pub fn tools(self) -> Vec<ToolName> {
let mut tools = self.builtin_tools();
tools.extend(self.members());
tools
}
pub fn accepts(self, tools: &[ToolName]) -> bool {
let builtins = self.builtin_tools();
let members = self.members();
let (head, tail) = tools.split_at(tools.len().min(builtins.len()));
let mut allowed = members.iter();
head == builtins.as_slice() && tail.iter().all(|t| allowed.any(|m| m == t))
}
pub fn members(self) -> Vec<ToolName> {
crate::members::all()
.iter()
.filter(|m| m.profiles.contains(&self))
.map(|m| ToolName::Member(m.name.as_str()))
.collect()
}
pub fn builtin_tools(self) -> Vec<ToolName> {
match self {
Self::Kg => vec![ToolName::Quipu, ToolName::Camayoc],
Self::Retrieval => vec![ToolName::Quipu, ToolName::Camayoc, ToolName::Bobbin],
Self::CodeIntel => vec![
ToolName::Quipu,
ToolName::Camayoc,
ToolName::Bobbin,
ToolName::Yupana,
],
Self::Crew => vec![ToolName::Quipu, ToolName::Camayoc, ToolName::Bobbin],
Self::Everything => vec![
ToolName::Quipu,
ToolName::Camayoc,
ToolName::Bobbin,
ToolName::Yupana,
ToolName::DesirePath,
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CrewMode {
Shantytown,
Creel,
Both,
Standalone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CrewOwner {
Shantytown,
Creel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CrewRouting {
SingleOwner,
ExplicitHandoff,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CrewSelection {
pub mode: CrewMode,
pub durable_owner: Option<CrewOwner>,
pub burst_owner: Option<CrewOwner>,
pub routing: Option<CrewRouting>,
pub policy: CrewPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CrewPolicy {
pub identity_source: IdentitySource,
pub model: Option<String>,
pub tools: Vec<ToolName>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdentitySource {
Quipu,
}
impl CrewPolicy {
fn standard() -> Self {
Self {
identity_source: IdentitySource::Quipu,
model: None,
tools: Profile::Crew.tools(),
}
}
fn validate(&self) -> Result<()> {
if !Profile::Crew.accepts(&self.tools) {
bail!("crew policy tools must match the crew profile conventions");
}
if self
.model
.as_ref()
.is_some_and(|model| model.trim().is_empty())
{
bail!("crew policy model must be omitted or non-empty");
}
Ok(())
}
}
impl CrewSelection {
pub fn for_mode(mode: CrewMode) -> Self {
match mode {
CrewMode::Shantytown => Self {
mode,
durable_owner: Some(CrewOwner::Shantytown),
burst_owner: None,
routing: Some(CrewRouting::SingleOwner),
policy: CrewPolicy::standard(),
},
CrewMode::Creel => Self {
mode,
durable_owner: None,
burst_owner: Some(CrewOwner::Creel),
routing: Some(CrewRouting::SingleOwner),
policy: CrewPolicy::standard(),
},
CrewMode::Both => Self {
mode,
durable_owner: Some(CrewOwner::Shantytown),
burst_owner: Some(CrewOwner::Creel),
routing: Some(CrewRouting::ExplicitHandoff),
policy: CrewPolicy::standard(),
},
CrewMode::Standalone => Self {
mode,
durable_owner: None,
burst_owner: None,
routing: None,
policy: CrewPolicy::standard(),
},
}
}
fn validate(&self) -> Result<()> {
let mut expected = Self::for_mode(self.mode);
expected.policy = self.policy.clone();
if self != &expected {
bail!(
"crew {:?} must use the declared ownership/routing contract; expected {:?}",
self.mode,
expected
);
}
self.policy.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ToolName {
Quipu,
Camayoc,
Bobbin,
Yupana,
DesirePath,
Member(&'static str),
}
impl ToolName {
pub const BUILTINS: [ToolName; 5] = [
Self::Quipu,
Self::Camayoc,
Self::Bobbin,
Self::Yupana,
Self::DesirePath,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Quipu => "quipu",
Self::Camayoc => "camayoc",
Self::Bobbin => "bobbin",
Self::Yupana => "yupana",
Self::DesirePath => "desire-path",
Self::Member(name) => name,
}
}
pub fn parse(name: &str) -> Option<Self> {
if let Some(builtin) = Self::BUILTINS.into_iter().find(|t| t.as_str() == name) {
return Some(builtin);
}
Some(Self::Member(crate::members::get(name)?.name.as_str()))
}
}
impl Serialize for ToolName {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ToolName {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let name = String::deserialize(d)?;
Self::parse(&name).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown tool '{name}': neither a built-in tool nor a stack member of this \
caboodle build (a member removed since the plan was written, or a plan from \
a newer caboodle); re-plan or use the caboodle that wrote it"
))
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum QuipuFlavor {
#[default]
Release,
Lancedb,
}
impl QuipuFlavor {
fn is_release(&self) -> bool {
*self == Self::Release
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
pub schema_version: u32,
pub profile: Profile,
pub tools: Vec<ToolName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shares: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quipu_db: Option<PathBuf>,
#[serde(default, skip_serializing_if = "QuipuFlavor::is_release")]
pub quipu_flavor: QuipuFlavor,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub external_quipu: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack_config: Option<StackConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crew: Option<CrewSelection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<InstallIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding_model: Option<crate::embedding::EmbeddingModel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quipu_mcp: Option<QuipuMcp>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_profiles: Vec<HostProfile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostProfile {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crew_excluded_owner: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub excluded_tools: Vec<ExcludedTool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExcludedTool {
pub tool: ToolName,
pub owner: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuipuMcp {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_file: Option<String>,
}
impl QuipuMcp {
fn validate(&self) -> Result<()> {
require_safe_text("quipu_mcp.url", &self.url)?;
let rest = self
.url
.strip_prefix("https://")
.or_else(|| self.url.strip_prefix("http://"))
.ok_or_else(|| anyhow::anyhow!("quipu_mcp.url must be an http:// or https:// URL"))?;
let authority = rest.split('/').next().unwrap_or("");
if authority.is_empty() || authority.contains('@') || self.url.contains(['?', '#', ' ']) {
bail!(
"quipu_mcp.url must be a plain server URL with no credentials, query or fragment"
);
}
if let Some(path) = &self.token_file {
require_safe_text("quipu_mcp.token_file", path)?;
if !path.starts_with('/') || path.contains(['\'', '\n', '\r']) {
bail!(
"quipu_mcp.token_file must be an absolute path without quotes or line breaks"
);
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StackConfig {
pub quipu: QuipuStackConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuipuStackConfig {
pub owl: QuipuOwlConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuipuOwlConfig {
pub reactive_materialize: bool,
}
impl StackConfig {
pub fn recommended() -> Self {
Self {
quipu: QuipuStackConfig {
owl: QuipuOwlConfig {
reactive_materialize: true,
},
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstallIntent {
pub intended_use: String,
#[serde(default)]
pub crew_members: Vec<CrewMemberTheme>,
pub anticipated_questions: Vec<QuestionContract>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CrewMemberTheme {
pub name: String,
pub theme: String,
pub domain: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuestionContract {
pub question: String,
pub answer_shape: String,
pub seed_intent: String,
pub sparql: String,
pub expected: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub self_test: bool,
}
pub const SELF_TEST_MARKER: &str = "caboodle-verify-roundtrip";
impl QuestionContract {
pub fn self_test() -> Self {
Self {
question: "does the knowledge store answer a query about a fact written to it?".to_owned(),
answer_shape: "one entity label".to_owned(),
seed_intent: format!(
"caboodle writes one Verification node named {SELF_TEST_MARKER} into a scratch store"
),
sparql: format!(
"SELECT ?s ?label WHERE {{ ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label . FILTER(?label = \"{SELF_TEST_MARKER}\") }}"
),
expected: SELF_TEST_MARKER.to_owned(),
self_test: true,
}
}
pub fn is_self_test(&self) -> bool {
self.self_test
|| *self
== Self {
self_test: false,
..Self::self_test()
}
}
}
impl InstallIntent {
pub fn read(path: &Path) -> Result<Self> {
if !path.exists() {
bail!(
"no intent file at {}; run `tambo init --guided` to answer the interview instead, or write one (see the \"Using caboodle\" chapter of docs/book)",
path.display()
);
}
let body =
fs::read_to_string(path).with_context(|| format!("read intent {}", path.display()))?;
let intent: Self =
toml::from_str(&body).with_context(|| format!("parse intent {}", path.display()))?;
intent.validate()?;
Ok(intent)
}
pub fn validate(&self) -> Result<()> {
require_safe_text("intended use", &self.intended_use)?;
if self.anticipated_questions.is_empty() {
bail!("intent requires at least one anticipated ontology question");
}
let mut names = std::collections::BTreeSet::new();
for member in &self.crew_members {
for (field, value) in [
("crew member name", &member.name),
("crew member theme", &member.theme),
("crew member domain", &member.domain),
("crew member role", &member.role),
] {
require_safe_text(field, value)?;
}
if !names.insert(member.name.to_ascii_lowercase()) {
bail!("duplicate crew member name {:?}", member.name);
}
}
let mut questions = std::collections::BTreeSet::new();
for contract in &self.anticipated_questions {
for (field, value) in [
("anticipated question", &contract.question),
("answer shape", &contract.answer_shape),
("seed intent", &contract.seed_intent),
("expected answer", &contract.expected),
] {
require_safe_text(field, value)?;
}
if !questions.insert(contract.question.to_ascii_lowercase()) {
bail!("duplicate anticipated question {:?}", contract.question);
}
let query = contract.sparql.to_ascii_uppercase();
if !(query.split_whitespace().any(|word| word == "SELECT")
|| query.split_whitespace().any(|word| word == "ASK"))
{
bail!("anticipated question SPARQL must contain SELECT or ASK");
}
require_safe_text("anticipated question SPARQL", &contract.sparql)?;
}
Ok(())
}
}
fn require_safe_text(field: &str, value: &str) -> Result<()> {
if value.trim().is_empty() {
bail!("{field} must not be empty");
}
let lower = value.to_ascii_lowercase();
for marker in [
"password=",
"token=",
"secret=",
"authorization:",
"bearer ",
] {
if lower.contains(marker) {
bail!("{field} appears to contain a credential ({marker})");
}
}
Ok(())
}
impl Plan {
pub fn requires_remote_quipu(&self) -> bool {
self.quipu_mcp.is_some()
|| std::env::var("QUIPU_SERVER").is_ok_and(|url| !url.trim().is_empty())
}
pub fn for_profile(profile: Profile) -> Self {
Self {
schema_version: SCHEMA_VERSION,
profile,
tools: profile.tools(),
shares: Vec::new(),
quipu_db: None,
quipu_flavor: QuipuFlavor::default(),
external_quipu: false,
stack_config: Some(StackConfig::recommended()),
crew: None,
intent: None,
embedding_model: None,
quipu_mcp: None,
host_profiles: Vec::new(),
}
}
pub fn for_crew(mode: CrewMode) -> Self {
Self {
profile: Profile::Crew,
crew: Some(CrewSelection::for_mode(mode)),
..Self::for_profile(Profile::Crew)
}
}
pub fn read(path: &Path) -> Result<Self> {
if !path.exists() {
bail!(
"no plan at {}; run `tambo init --guided` (or `tambo plan`) first",
path.display()
);
}
let body =
fs::read_to_string(path).with_context(|| format!("read plan {}", path.display()))?;
let plan: Self =
toml::from_str(&body).with_context(|| format!("parse plan {}", path.display()))?;
plan.validate()?;
Ok(plan)
}
pub fn write(&self, path: &Path) -> Result<()> {
self.validate()?;
let body = toml::to_string_pretty(self).context("serialize plan")?;
fs::write(path, body).with_context(|| format!("write plan {}", path.display()))
}
pub fn validate(&self) -> Result<()> {
if self.schema_version != SCHEMA_VERSION {
bail!(
"unsupported plan schema {}; expected {}",
self.schema_version,
SCHEMA_VERSION
);
}
if self.external_quipu && self.quipu_flavor != QuipuFlavor::Release {
bail!("external_quipu cannot request a Tambo build flavor");
}
if self.tools.is_empty() {
bail!("plan selects no tools");
}
if !self.profile.accepts(&self.tools) {
bail!(
"tools do not match the {:?} profile conventions",
self.profile
);
}
if !self.shares.is_empty() && self.quipu_db.is_none() {
bail!("plans with knowledge shares require an explicit --quipu-db");
}
if self.shares.iter().any(|path| path.as_os_str().is_empty()) {
bail!("knowledge share paths must not be empty");
}
if let Some(intent) = &self.intent {
intent.validate()?;
}
if let Some(model) = &self.embedding_model {
model.validate()?;
}
if let Some(mcp) = &self.quipu_mcp {
if !self.tools.contains(&ToolName::Quipu) {
bail!("[quipu_mcp] requires a profile that installs quipu");
}
mcp.validate()?;
}
match (self.profile, &self.crew) {
(Profile::Crew, Some(crew)) => crew.validate()?,
(Profile::Crew, None) => bail!("crew profile requires a [crew] selection"),
(Profile::Everything, Some(crew)) => crew.validate()?,
(_, Some(_)) => bail!("[crew] is only valid with profile = \"crew\" or \"everything\""),
(_, None) => {}
}
let mut names = std::collections::HashSet::new();
for host in &self.host_profiles {
if host.name.trim().is_empty() || !names.insert(host.name.as_str()) {
bail!("host profile names must be non-empty and unique");
}
if host
.crew_excluded_owner
.as_deref()
.is_some_and(|owner| owner.trim().is_empty())
{
bail!(
"host profile {}: crew_excluded_owner must name the owning lane",
host.name
);
}
let mut excluded = std::collections::BTreeSet::new();
for entry in &host.excluded_tools {
if entry.owner.trim().is_empty() {
bail!(
"host profile {}: excluded tool {} must name the owning lane",
host.name,
entry.tool.as_str()
);
}
if !matches!(entry.tool, ToolName::Member(_))
|| !self.tools.contains(&entry.tool)
|| !excluded.insert(entry.tool)
{
bail!(
"host profile {}: excluded tool {} must be a selected stack member, listed once",
host.name,
entry.tool.as_str()
);
}
}
}
Ok(())
}
pub fn for_host(mut self, host: Option<&str>) -> Result<(Self, Vec<String>)> {
let Some(host) = host else {
return Ok((self, Vec::new()));
};
let profile = self
.host_profiles
.iter()
.find(|p| p.name == host)
.cloned()
.with_context(|| format!("plan has no host profile named {host}"))?;
let mut excluded = Vec::new();
if let Some(owner) = profile.crew_excluded_owner {
if self.profile == Profile::Crew {
bail!("host profile {host} excludes crew, but this plan's profile IS crew");
}
if self.crew.take().is_some() {
excluded.push(format!(
"crew: EXCLUDED on host profile {host}; owned by {owner}; not installed or verified by caboodle"
));
}
}
if let Some(applied) = self.host_profiles.iter_mut().find(|p| p.name == host) {
applied.excluded_tools.clear();
}
for entry in &profile.excluded_tools {
if let Some(index) = self.tools.iter().position(|t| *t == entry.tool) {
self.tools.remove(index);
excluded.push(format!(
"{}: EXCLUDED on host profile {host}; owned by {}; not installed or verified by caboodle",
entry.tool.as_str(),
entry.owner
));
}
}
Ok((self, excluded))
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct State {
pub schema_version: u32,
pub tools: BTreeMap<String, ToolState>,
#[serde(default)]
pub crew: BTreeMap<String, CrewRuntimeState>,
#[serde(default)]
pub shares: BTreeMap<String, ShareState>,
#[serde(default)]
pub models: BTreeMap<String, ModelArtifactState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolState {
pub version: String,
pub applied: bool,
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrewRuntimeState {
pub version: String,
pub applied: bool,
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelArtifactState {
pub path: PathBuf,
pub sha256: String,
pub provisioned: bool,
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShareState {
pub path: PathBuf,
pub staging_graph: String,
pub outcome: String,
pub promotion_eligible: bool,
pub blockers: Vec<String>,
}
impl State {
pub fn read(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self {
schema_version: SCHEMA_VERSION,
tools: BTreeMap::new(),
crew: BTreeMap::new(),
shares: BTreeMap::new(),
models: BTreeMap::new(),
});
}
let body =
fs::read_to_string(path).with_context(|| format!("read state {}", path.display()))?;
let state: Self = serde_json::from_str(&body)
.with_context(|| format!("parse state {}", path.display()))?;
if state.schema_version != SCHEMA_VERSION {
bail!("unsupported state schema {}", state.schema_version);
}
Ok(state)
}
pub fn write(&self, path: &Path) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)
.with_context(|| format!("create state directory {}", parent.display()))?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("create temporary state in {}", parent.display()))?;
serde_json::to_writer_pretty(&mut tmp, self).context("serialize state")?;
use std::io::Write;
writeln!(tmp).context("finish state")?;
tmp.persist(path)
.map_err(|error| error.error)
.with_context(|| format!("replace state {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod self_test_question_tests {
use super::QuestionContract;
#[test]
fn self_test_is_the_flag_never_the_marker() {
assert!(QuestionContract::self_test().is_self_test());
let mut users_own = QuestionContract::self_test();
users_own.self_test = false;
users_own.question = "what depends on the service?".to_owned();
assert!(!users_own.is_self_test());
let legacy = QuestionContract {
self_test: false,
..QuestionContract::self_test()
};
assert!(legacy.is_self_test());
}
#[test]
fn the_flag_round_trips_and_is_omitted_for_user_questions() {
let toml = toml::to_string(&QuestionContract::self_test()).unwrap();
assert!(toml.contains("self_test = true"));
let mut user = QuestionContract::self_test();
user.self_test = false;
user.question = "q".to_owned();
assert!(!toml::to_string(&user).unwrap().contains("self_test"));
}
}
#[cfg(test)]
mod host_profile_tests {
use super::{CrewMode, ExcludedTool, HostProfile, Plan, Profile, ToolName};
fn plan() -> Plan {
let mut plan = Plan::for_profile(Profile::Everything);
plan.crew = Plan::for_crew(CrewMode::Both).crew;
plan.host_profiles = vec![HostProfile {
name: "mac".into(),
crew_excluded_owner: Some("st-peer-autopull".into()),
excluded_tools: Vec::new(),
}];
plan
}
#[test]
fn host_profile_drops_an_excluded_tool_and_names_its_owner() {
let mut excluding = plan();
let tool = *excluding
.tools
.iter()
.find(|t| matches!(t, ToolName::Member(_)))
.expect("the everything profile carries stack members");
excluding.host_profiles[0].excluded_tools = vec![ExcludedTool {
tool,
owner: "its own release lane".into(),
}];
excluding.validate().unwrap();
let (selected, lines) = excluding.clone().for_host(Some("mac")).unwrap();
assert!(!selected.tools.contains(&tool));
assert!(lines.iter().any(|l| l.starts_with(tool.as_str())
&& l.contains("EXCLUDED")
&& l.contains("its own release lane")));
selected.validate().unwrap();
let (unchanged, _) = excluding.for_host(None).unwrap();
assert!(unchanged.tools.contains(&tool));
}
#[test]
fn excluded_tools_must_be_owned_selected_and_listed_once() {
let mut ownerless = plan();
let tool = *ownerless
.tools
.iter()
.find(|t| matches!(t, ToolName::Member(_)))
.unwrap();
ownerless.host_profiles[0].excluded_tools = vec![ExcludedTool {
tool,
owner: " ".into(),
}];
assert!(ownerless.validate().is_err());
let mut twice = plan();
let entry = ExcludedTool {
tool,
owner: "lane".into(),
};
twice.host_profiles[0].excluded_tools = vec![entry.clone(), entry.clone()];
assert!(twice.validate().is_err());
let mut unselected = plan();
unselected.tools.retain(|t| *t != tool);
unselected.host_profiles[0].excluded_tools = vec![ExcludedTool {
tool,
owner: "lane".into(),
}];
assert!(unselected.validate().is_err());
let mut builtin = plan();
builtin.host_profiles[0].excluded_tools = vec![ExcludedTool {
tool: ToolName::Bobbin,
owner: "lane".into(),
}];
assert!(builtin.validate().is_err());
}
#[test]
fn host_profile_drops_crew_and_names_the_owner() {
let (selected, lines) = plan().for_host(Some("mac")).unwrap();
assert!(selected.crew.is_none());
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("EXCLUDED") && lines[0].contains("st-peer-autopull"));
let (unchanged, none) = plan().for_host(None).unwrap();
assert!(unchanged.crew.is_some() && none.is_empty());
assert!(plan().for_host(Some("nonesuch")).is_err());
}
#[test]
fn host_profiles_must_be_named_uniquely_and_own_what_they_exclude() {
let mut duplicate = plan();
duplicate
.host_profiles
.push(duplicate.host_profiles[0].clone());
assert!(duplicate.validate().is_err());
let mut ownerless = plan();
ownerless.host_profiles[0].crew_excluded_owner = Some(" ".into());
assert!(ownerless.validate().is_err());
let mut crew_only = Plan::for_crew(CrewMode::Both);
crew_only.host_profiles = plan().host_profiles;
assert!(crew_only.for_host(Some("mac")).is_err());
}
}