use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub(crate) const DEFAULT_MODEL_YAML: &str =
include_str!("../../../../../examples/issue-state/unicorn-factory.yaml");
pub(crate) const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct StateModel {
pub(crate) version: u32,
pub(crate) label_config: LabelConfig,
pub(crate) states: Vec<StateDef>,
#[serde(default)]
pub(crate) extra_labels: Vec<ExtraLabel>,
pub(crate) transitions: Vec<Transition>,
pub(crate) assignee_model: AssigneeModel,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LabelConfig {
pub(crate) base: String,
pub(crate) approved: String,
pub(crate) blast_prefix: String,
pub(crate) status_prefix: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct StateDef {
pub(crate) name: String,
pub(crate) label: StateLabel,
#[serde(default)]
pub(crate) order: Option<u32>,
#[serde(default)]
pub(crate) terminal: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct StateLabel {
pub(crate) name: String,
pub(crate) color: String,
#[serde(default)]
pub(crate) description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ExtraLabel {
pub(crate) name: String,
pub(crate) color: String,
#[serde(default)]
pub(crate) description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Transition {
#[serde(default)]
pub(crate) from: Option<String>,
pub(crate) to: String,
pub(crate) trigger: Trigger,
#[serde(default)]
pub(crate) description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Trigger {
IssueCreated,
HumanLabel,
ExecutorStart,
ExecutorComplete,
ExecutorFailure,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct AssigneeModel {
pub(crate) strategy: String,
#[serde(default)]
pub(crate) identity_pattern: Option<String>,
#[serde(default)]
pub(crate) identity_example: Option<String>,
#[serde(default)]
pub(crate) git_attribution: Option<serde_yaml::Value>,
#[serde(default)]
pub(crate) per_state: std::collections::BTreeMap<String, serde_yaml::Value>,
}
pub(crate) const CONFIG_BASENAME: &str = "issue-state.yaml";
pub(crate) fn user_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| {
h.join(".trusty-tools")
.join("trusty-mpm")
.join(CONFIG_BASENAME)
})
}
pub(crate) fn resolve_config_path(
flag: Option<&Path>,
cwd_exists: bool,
user_path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(f) = flag {
return Some(f.to_path_buf());
}
if cwd_exists {
return Some(PathBuf::from(CONFIG_BASENAME));
}
if let Some(u) = user_path
&& u.exists()
{
return Some(u.to_path_buf());
}
None
}
pub(crate) fn load_model(flag: Option<&Path>) -> anyhow::Result<StateModel> {
let user = user_config_path();
let cwd_path = PathBuf::from(CONFIG_BASENAME);
let chosen = resolve_config_path(flag, cwd_path.exists(), user.as_deref());
let yaml = match &chosen {
Some(path) => std::fs::read_to_string(path).map_err(|e| {
anyhow::anyhow!(
"failed to read issue-state config `{}`: {e}",
path.display()
)
})?,
None => DEFAULT_MODEL_YAML.to_string(),
};
let model: StateModel = serde_yaml::from_str(&yaml).map_err(|e| {
let src = chosen
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "embedded default".to_string());
anyhow::anyhow!("failed to parse issue-state model ({src}): {e}")
})?;
super::validate::validate_model(&model)?;
Ok(model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_default_parses() {
let m: StateModel = serde_yaml::from_str(DEFAULT_MODEL_YAML).expect("default parses");
assert_eq!(m.version, SUPPORTED_VERSION);
assert_eq!(m.label_config.base, "unicorn");
let names: Vec<&str> = m.states.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec![
"queued",
"approved",
"active-development",
"paused",
"blocked",
"done",
"failed"
]
);
assert!(
!names.contains(&"in-review"),
"there must be no in-review state"
);
let terminals: Vec<&str> = m
.states
.iter()
.filter(|s| s.terminal)
.map(|s| s.name.as_str())
.collect();
assert_eq!(terminals, vec!["done", "failed"]);
let entry = m
.transitions
.iter()
.find(|t| t.from.is_none())
.expect("creation edge");
assert_eq!(entry.to, "queued");
assert_eq!(entry.trigger, Trigger::IssueCreated);
assert_eq!(m.assignee_model.strategy, "bot_identity");
assert!(m.extra_labels.iter().any(|l| l.name == "blast:high"));
assert!(m.extra_labels.iter().any(|l| l.name == "approval:level-1"));
}
#[test]
fn embedded_default_round_trips() {
let m: StateModel = serde_yaml::from_str(DEFAULT_MODEL_YAML).expect("parse");
let s = serde_yaml::to_string(&m).expect("serialize");
let m2: StateModel = serde_yaml::from_str(&s).expect("reparse");
assert_eq!(m, m2);
}
#[test]
fn load_embedded_default_ok() {
let model: StateModel = serde_yaml::from_str(DEFAULT_MODEL_YAML).expect("default parses");
super::super::validate::validate_model(&model).expect("default is valid");
}
#[test]
fn load_explicit_missing_errors() {
let missing = Path::new("/nonexistent/issue-state-does-not-exist.yaml");
let err = load_model(Some(missing)).unwrap_err().to_string();
assert!(err.contains("failed to read"), "got: {err}");
}
#[test]
fn resolve_prefers_flag() {
let flag = PathBuf::from("/tmp/custom.yaml");
let got = resolve_config_path(Some(&flag), true, None);
assert_eq!(got, Some(flag));
}
#[test]
fn resolve_prefers_cwd() {
let got = resolve_config_path(None, true, None);
assert_eq!(got, Some(PathBuf::from(CONFIG_BASENAME)));
}
#[test]
fn resolve_none_means_default() {
let got = resolve_config_path(None, false, None);
assert_eq!(got, None);
}
#[test]
fn user_config_path_uses_basename() {
if let Some(p) = user_config_path() {
assert!(p.ends_with(CONFIG_BASENAME));
assert!(p.to_string_lossy().contains(".trusty-tools"));
assert!(p.to_string_lossy().contains("trusty-mpm"));
}
}
}