use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
use crate::instructions::defaults;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoleSpec {
Builtin(BuiltinRole),
Custom { name: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BuiltinRole {
Architect,
Planner,
General,
}
impl BuiltinRole {
pub fn canonical_name(self) -> &'static str {
match self {
BuiltinRole::Architect => "architect",
BuiltinRole::Planner => "planner",
BuiltinRole::General => "general",
}
}
pub fn prompt_md(self) -> &'static str {
match self {
BuiltinRole::Architect => defaults::DEFAULT_ARCHITECT,
BuiltinRole::Planner => defaults::DEFAULT_PLANNER,
BuiltinRole::General => defaults::DEFAULT_GENERAL,
}
}
}
impl FromStr for BuiltinRole {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"architect" => Ok(BuiltinRole::Architect),
"planner" => Ok(BuiltinRole::Planner),
"general" => Ok(BuiltinRole::General),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsTemplate {
family: SettingsFamily,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SettingsFamily {
Default,
}
impl SettingsTemplate {
pub const fn default_family() -> Self {
Self {
family: SettingsFamily::Default,
}
}
pub fn render(&self, status_file_path: &str) -> String {
match self.family {
SettingsFamily::Default => {
crate::instructions::generate_expert_settings(status_file_path)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRole {
pub canonical_name: String,
pub prompt_md: String,
pub settings_template: SettingsTemplate,
}
#[derive(Debug, Error)]
pub enum RoleError {
#[error("custom role template not found: {0}")]
NotFound(String),
#[error("custom role template is not valid UTF-8: {0}")]
InvalidUtf8(PathBuf),
#[error("failed to read custom role template at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn resolve(spec: &RoleSpec, project_root: &Path) -> Result<ResolvedRole, RoleError> {
match spec {
RoleSpec::Builtin(role) => Ok(ResolvedRole {
canonical_name: role.canonical_name().to_string(),
prompt_md: role.prompt_md().to_string(),
settings_template: SettingsTemplate::default_family(),
}),
RoleSpec::Custom { name } => {
let prompt_md = read_custom_template(project_root, name)?;
Ok(ResolvedRole {
canonical_name: name.clone(),
prompt_md,
settings_template: SettingsTemplate::default_family(),
})
}
}
}
fn read_custom_template(project_root: &Path, name: &str) -> Result<String, RoleError> {
let project_local = project_root
.join(".macot")
.join("templates")
.join("roles")
.join(format!("{name}.md"));
if let Some(content) = read_if_exists(&project_local)? {
return Ok(content);
}
if let Some(user_global) = user_global_template(name) {
if let Some(content) = read_if_exists(&user_global)? {
return Ok(content);
}
}
Err(RoleError::NotFound(name.to_string()))
}
fn read_if_exists(path: &Path) -> Result<Option<String>, RoleError> {
if !path.exists() {
return Ok(None);
}
match std::fs::read(path) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(content) => Ok(Some(content)),
Err(_) => Err(RoleError::InvalidUtf8(path.to_path_buf())),
},
Err(source) => Err(RoleError::Io {
path: path.to_path_buf(),
source,
}),
}
}
fn user_global_template(name: &str) -> Option<PathBuf> {
dirs::config_dir().map(|cfg| cfg.join("macot").join("roles").join(format!("{name}.md")))
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use tempfile::TempDir;
fn write_template(root: &Path, name: &str, body: &str) {
let dir = root.join(".macot").join("templates").join("roles");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
}
#[test]
fn resolve_builtin_architect_returns_embedded_prompt() {
let tmp = TempDir::new().unwrap();
let resolved = resolve(&RoleSpec::Builtin(BuiltinRole::Architect), tmp.path()).unwrap();
assert_eq!(
resolved.canonical_name, "architect",
"resolve(builtin): canonical_name should be 'architect'"
);
assert_eq!(
resolved.prompt_md,
defaults::DEFAULT_ARCHITECT,
"resolve(builtin): prompt should match embedded default"
);
assert_eq!(
resolved.settings_template,
SettingsTemplate::default_family(),
"resolve(builtin): settings template should be default family"
);
}
#[test]
fn resolve_builtin_planner_and_general() {
let tmp = TempDir::new().unwrap();
let planner = resolve(&RoleSpec::Builtin(BuiltinRole::Planner), tmp.path()).unwrap();
assert_eq!(planner.canonical_name, "planner");
assert_eq!(planner.prompt_md, defaults::DEFAULT_PLANNER);
let general = resolve(&RoleSpec::Builtin(BuiltinRole::General), tmp.path()).unwrap();
assert_eq!(general.canonical_name, "general");
assert_eq!(general.prompt_md, defaults::DEFAULT_GENERAL);
}
#[test]
fn resolve_custom_reads_project_local_template() {
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "qa-bot", "# QA Bot\n\nbody");
let resolved = resolve(
&RoleSpec::Custom {
name: "qa-bot".to_string(),
},
tmp.path(),
)
.unwrap();
assert_eq!(resolved.canonical_name, "qa-bot");
assert_eq!(resolved.prompt_md, "# QA Bot\n\nbody");
}
#[test]
fn resolve_custom_missing_template_returns_not_found() {
let tmp = TempDir::new().unwrap();
let cfg_home = TempDir::new().unwrap();
std::env::set_var("XDG_CONFIG_HOME", cfg_home.path());
std::env::set_var("HOME", cfg_home.path());
let result = resolve(
&RoleSpec::Custom {
name: "missing".to_string(),
},
tmp.path(),
);
assert!(
matches!(result, Err(RoleError::NotFound(ref n)) if n == "missing"),
"resolve(custom missing): expected NotFound, got {result:?}"
);
}
#[test]
fn resolve_custom_rejects_invalid_utf8() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".macot").join("templates").join("roles");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("badbytes.md");
std::fs::write(&path, [0xFFu8, 0xFE, 0xFD]).unwrap();
let result = resolve(
&RoleSpec::Custom {
name: "badbytes".to_string(),
},
tmp.path(),
);
match result {
Err(RoleError::InvalidUtf8(p)) => assert_eq!(p, path),
other => panic!("resolve(custom invalid utf8): expected InvalidUtf8, got {other:?}"),
}
}
#[test]
fn settings_template_renders_status_path() {
let tpl = SettingsTemplate::default_family();
let rendered = tpl.render("/tmp/status/expert3");
assert!(
rendered.contains("/tmp/status/expert3"),
"render: should embed the status file path"
);
let _: serde_json::Value =
serde_json::from_str(&rendered).expect("render: result should be valid JSON");
}
#[test]
fn builtin_from_str_round_trip() {
for r in [
BuiltinRole::Architect,
BuiltinRole::Planner,
BuiltinRole::General,
] {
let parsed: BuiltinRole = r.canonical_name().parse().unwrap();
assert_eq!(parsed, r);
}
assert!(BuiltinRole::from_str("nope").is_err());
}
proptest! {
#[test]
fn resolve_is_deterministic_for_fixed_fs(
kind in 0u8..6,
custom_body in "[a-zA-Z0-9 \n#]{0,128}",
) {
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "fixture-role", &custom_body);
let spec = match kind {
0 => RoleSpec::Builtin(BuiltinRole::Architect),
1 => RoleSpec::Builtin(BuiltinRole::Planner),
2 => RoleSpec::Builtin(BuiltinRole::General),
3 => RoleSpec::Custom { name: "fixture-role".to_string() },
4 => RoleSpec::Builtin(BuiltinRole::Architect),
_ => RoleSpec::Custom { name: "fixture-role".to_string() },
};
let first = resolve(&spec, tmp.path()).unwrap();
let second = resolve(&spec, tmp.path()).unwrap();
prop_assert_eq!(&first.canonical_name, &second.canonical_name);
prop_assert_eq!(&first.prompt_md, &second.prompt_md);
prop_assert_eq!(&first.settings_template, &second.settings_template);
}
}
}