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 STATUS_LIFECYCLE_YAML: &str =
include_str!("../../../../../examples/issue-state/status-lifecycle.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,
#[serde(default)]
pub(crate) label: Option<StateLabel>,
#[serde(default)]
pub(crate) gh_state: GhState,
#[serde(default)]
pub(crate) order: Option<u32>,
#[serde(default)]
pub(crate) terminal: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum GhState {
#[default]
Open,
Closed,
}
#[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) requires_note: bool,
#[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 discover_upward(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(current) = dir {
let candidate = current.join(CONFIG_BASENAME);
if candidate.is_file() {
return Some(candidate);
}
if current.join(".git").exists() {
return None;
}
dir = current.parent();
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ModelSource {
File(PathBuf),
EmbeddedDefault {
searched_from: PathBuf,
},
EmbeddedStatusLifecycle {
searched_from: PathBuf,
},
}
pub(crate) fn describe_source(source: &ModelSource) -> String {
match source {
ModelSource::File(path) => format!("state model: {}", path.display()),
ModelSource::EmbeddedDefault { searched_from } => format!(
"state model: the BUILT-IN default — no `{CONFIG_BASENAME}` was found from {} up to \
the git toplevel, and none is configured, so the states above are the built-in ones \
and not this project's. Run from the repository root or pass \
`--config <repo-root>/{CONFIG_BASENAME}` (#7580)",
searched_from.display()
),
ModelSource::EmbeddedStatusLifecycle { searched_from } => format!(
"state model: the BUILT-IN `status:*` lifecycle (open → status:in-progress → \
status:coded → status:merged → status:tested → closed) — no `{CONFIG_BASENAME}` was \
found from {} up to the git toplevel, and the target names one of its states (#8609)",
searched_from.display()
),
}
}
pub(crate) fn builtin_for_target(
loaded: (StateModel, ModelSource),
to: &str,
) -> anyhow::Result<(StateModel, ModelSource)> {
let (model, source) = loaded;
let ModelSource::EmbeddedDefault { searched_from } = &source else {
return Ok((model, source));
};
if model.states.iter().any(|s| s.name == to) {
return Ok((model, source));
}
let lifecycle: StateModel = serde_yaml::from_str(STATUS_LIFECYCLE_YAML)
.map_err(|e| anyhow::anyhow!("failed to parse the built-in status:* lifecycle: {e}"))?;
if !lifecycle.states.iter().any(|s| s.name == to) {
return Ok((model, source));
}
super::validate::validate_model(&lifecycle)?;
let searched_from = searched_from.clone();
Ok((
lifecycle,
ModelSource::EmbeddedStatusLifecycle { searched_from },
))
}
pub(crate) fn resolve_config_path(
flag: Option<&Path>,
discovered: Option<PathBuf>,
configured: Option<&Path>,
user_path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(f) = flag {
return Some(f.to_path_buf());
}
if let Some(found) = discovered {
return Some(found);
}
if let Some(c) = configured {
return Some(c.to_path_buf());
}
if let Some(u) = user_path
&& u.exists()
{
return Some(u.to_path_buf());
}
None
}
pub(crate) fn load_model_with_source(
flag: Option<&Path>,
configured: Option<&Path>,
) -> anyhow::Result<(StateModel, ModelSource)> {
let start = std::env::current_dir().map_err(|e| {
anyhow::anyhow!(
"cannot resolve the current directory to search for `{CONFIG_BASENAME}` from: {e}"
)
})?;
load_model_in(&start, flag, configured)
}
pub(crate) fn load_model_in(
start: &Path,
flag: Option<&Path>,
configured: Option<&Path>,
) -> anyhow::Result<(StateModel, ModelSource)> {
let user = user_config_path();
let discovered = if flag.is_some() {
None
} else {
discover_upward(start)
};
let chosen = resolve_config_path(flag, discovered, configured, 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)?;
let source = match chosen {
Some(path) => ModelSource::File(path),
None => ModelSource::EmbeddedDefault {
searched_from: start.to_path_buf(),
},
};
Ok((model, source))
}
#[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_in(Path::new("/"), Some(missing), None)
.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 repo = PathBuf::from("/repo").join(CONFIG_BASENAME);
let got = resolve_config_path(Some(&flag), Some(repo), None, None);
assert_eq!(got, Some(flag));
}
#[test]
fn resolve_prefers_cwd() {
let repo = PathBuf::from("/repo").join(CONFIG_BASENAME);
let got = resolve_config_path(None, Some(repo.clone()), None, None);
assert_eq!(got, Some(repo));
}
#[test]
fn resolve_none_means_default() {
let got = resolve_config_path(None, None, None, None);
assert_eq!(got, None);
}
#[test]
fn resolve_prefers_cwd_over_configured() {
let configured = PathBuf::from("/tmp/host-issue-state.yaml");
let repo = PathBuf::from("/repo").join(CONFIG_BASENAME);
let got = resolve_config_path(None, Some(repo.clone()), Some(&configured), None);
assert_eq!(got, Some(repo));
}
#[test]
fn resolve_uses_configured_before_user() {
let configured = PathBuf::from("/tmp/host-issue-state.yaml");
let user = PathBuf::from("/tmp/user-issue-state.yaml");
let got = resolve_config_path(None, None, Some(&configured), Some(&user));
assert_eq!(got, Some(configured));
}
fn repo_with_model() -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join(".git")).expect("git dir");
std::fs::write(root.join(CONFIG_BASENAME), DEFAULT_MODEL_YAML).expect("model");
let sub = root.join("crates").join("trusty-mpm");
std::fs::create_dir_all(&sub).expect("subdir");
(tmp, root, sub)
}
#[test]
fn discover_finds_the_repo_root_model_from_a_crate_subdirectory_7580() {
let (_tmp, root, sub) = repo_with_model();
assert_eq!(discover_upward(&sub), Some(root.join(CONFIG_BASENAME)));
}
#[test]
fn discover_stops_at_the_git_toplevel_7580() {
let tmp = tempfile::tempdir().expect("tempdir");
let outer = tmp.path();
std::fs::write(outer.join(CONFIG_BASENAME), DEFAULT_MODEL_YAML).expect("outer model");
let repo = outer.join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git dir");
let sub = repo.join("crates");
std::fs::create_dir_all(&sub).expect("subdir");
assert_eq!(discover_upward(&sub), None);
}
#[test]
fn discover_finds_nothing_when_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("git dir");
assert_eq!(discover_upward(tmp.path()), None);
}
#[test]
fn load_in_finds_the_repo_root_model_from_a_crate_subdirectory_7580() {
let (_tmp, root, sub) = repo_with_model();
let (_model, source) = load_model_in(&sub, None, None).expect("loads");
assert_eq!(source, ModelSource::File(root.join(CONFIG_BASENAME)));
}
#[test]
fn load_in_reports_the_embedded_default_source_7580() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("git dir");
let (_model, source) = load_model_in(tmp.path(), None, None).expect("loads");
if let ModelSource::EmbeddedDefault { searched_from } = source {
assert_eq!(searched_from, tmp.path());
}
}
#[test]
fn describe_source_names_the_embedded_default_7580() {
let note = describe_source(&ModelSource::EmbeddedDefault {
searched_from: PathBuf::from("/repo/crates/trusty-mpm"),
});
assert!(note.contains("BUILT-IN default"), "{note}");
assert!(note.contains("/repo/crates/trusty-mpm"), "{note}");
assert!(note.contains("--config"), "{note}");
}
#[test]
fn describe_source_names_the_file() {
let note = describe_source(&ModelSource::File(PathBuf::from("/repo/issue-state.yaml")));
assert_eq!(note, "state model: /repo/issue-state.yaml");
}
#[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"));
}
}
}