use crate::config::Config;
use crate::error::{Result, TixError};
use crate::triage::inbox;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(clippy::module_name_repetitions)]
pub enum ImportPolicy {
#[default]
Default,
Entry,
Remote,
}
impl ImportPolicy {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Entry => "entry",
Self::Remote => "remote",
}
}
pub fn parse(raw: &str) -> Result<Self> {
let normalised = raw.trim().to_ascii_lowercase();
match normalised.as_str() {
"entry" => Ok(Self::Entry),
"remote" => Ok(Self::Remote),
_ => Err(TixError::InvalidInput(format!(
"unknown --triage-policy '{raw}' — use entry or remote"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct Router {
policy: ImportPolicy,
entry: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mapping {
pub original: String,
pub requested: String,
pub landed: String,
pub policy: ImportPolicy,
pub routed: bool,
}
impl Mapping {
#[must_use]
pub fn report_clause(&self) -> String {
if !self.routed && self.policy == ImportPolicy::Default {
return String::new();
}
let label = if self.routed {
"triage entry".to_string()
} else {
format!("import policy {}", self.policy.as_str())
};
format!(
" ({label}: remote `{}` requested `{}`, landed `{}`)",
self.original, self.requested, self.landed
)
}
}
pub fn resolve(config: &Config, policy: ImportPolicy) -> Result<Router> {
if policy == ImportPolicy::Remote {
return Ok(Router {
policy,
entry: None,
});
}
let entry = inbox::scope(config)?.map(|scope| scope.entry_status);
if policy == ImportPolicy::Entry && entry.is_none() {
return Err(TixError::InvalidInput(
"`--triage-policy entry` needs a `[triage]` section in tix.toml, and this project \
has none"
.to_string(),
));
}
Ok(Router { policy, entry })
}
impl Router {
#[must_use]
pub fn place(&self, original: &str, requested: &str) -> Mapping {
match self.entry.as_deref() {
Some(column) => Mapping {
original: original.to_string(),
requested: requested.to_string(),
landed: column.to_string(),
policy: self.policy,
routed: true,
},
None => Mapping {
original: original.to_string(),
requested: requested.to_string(),
landed: requested.to_string(),
policy: self.policy,
routed: false,
},
}
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::{BoardConfiguration, Config, KanbanSection};
use std::collections::BTreeMap;
use std::path::PathBuf;
fn board(columns: &[&str]) -> Config {
Config {
kanban: KanbanSection {
configurations: [(
"default".to_string(),
BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
..BoardConfiguration::default()
},
)]
.into_iter()
.collect(),
},
issues_dir: PathBuf::from("docs/issues"),
..Config::default()
}
}
fn triaged(columns: &[&str], entry: &str) -> Config {
let mut with_declined: Vec<&str> = columns.to_vec();
if !with_declined.contains(&"wontfix") {
with_declined.push("wontfix");
}
let mut config = board(&with_declined);
config
.kanban
.configurations
.get_mut("default")
.expect("board() installs one")
.category_map
.insert("wontfix".to_string(), "canceled".to_string());
config.triage = Some(crate::triage::TriageConfig {
entry_status: Some(entry.to_string()),
declined_status: Some("wontfix".to_string()),
..crate::triage::TriageConfig::default()
});
config
}
#[test]
fn triage_import_default_routes_into_the_entry_column() {
let config = triaged(&["todo", "triage", "in progress", "done"], "triage");
let router = resolve(&config, ImportPolicy::default()).expect("a valid section");
let placed = router.place("opened", "todo");
assert_eq!(
placed.landed, "triage",
"entry wins over the remote mapping"
);
assert_eq!(
placed.requested, "todo",
"the report still names what was asked"
);
assert!(placed.routed);
assert_eq!(
placed.report_clause(),
" (triage entry: remote `opened` requested `todo`, landed `triage`)"
);
}
#[test]
fn triage_import_default_without_a_triage_section_leaves_the_mapping_alone() {
let config = board(&["todo", "in progress", "done"]);
let router = resolve(&config, ImportPolicy::default()).expect("no section to validate");
let placed = router.place("opened", "todo");
assert_eq!(placed.landed, "todo");
assert!(!placed.routed);
assert_eq!(
placed.report_clause(),
"",
"nothing was decided, so nothing is announced and a legacy report is unchanged"
);
}
#[test]
fn triage_import_the_remote_policy_opts_out_even_where_an_entry_exists() {
let config = triaged(&["todo", "triage", "done"], "triage");
let router = resolve(&config, ImportPolicy::Remote).expect("an opt-out cannot fail");
let placed = router.place("opened", "todo");
assert_eq!(placed.landed, "todo", "the remote mapping stands");
assert!(!placed.routed);
assert_eq!(
placed.report_clause(),
" (import policy remote: remote `opened` requested `todo`, landed `todo`)",
"an explicit opt-out is still reported, or it would be silent"
);
}
#[test]
fn triage_import_the_entry_policy_refuses_a_project_with_no_triage_section() {
let config = board(&["todo", "in progress", "done"]);
let err = resolve(&config, ImportPolicy::Entry).expect_err("unrepresentable");
assert!(matches!(err, TixError::InvalidInput(_)), "{err:?}");
assert_eq!(
err.to_string(),
"invalid input: `--triage-policy entry` needs a `[triage]` section in tix.toml, and \
this project has none"
);
}
#[test]
fn triage_import_an_entry_column_the_board_lacks_is_refused_by_the_validator() {
let config = triaged(&["todo", "in progress", "done"], "nope");
let err = resolve(&config, ImportPolicy::Entry).expect_err("no such column");
assert!(matches!(err, TixError::Config(_)), "{err:?}");
let text = err.to_string();
assert!(text.contains("entry_status"), "{text}");
assert!(text.contains("nope"), "{text}");
}
#[test]
fn triage_import_parsing_refuses_anything_that_is_not_entry_or_remote() {
assert_eq!(ImportPolicy::parse("entry").unwrap(), ImportPolicy::Entry);
assert_eq!(
ImportPolicy::parse("Remote").unwrap(),
ImportPolicy::Remote,
"the flag is case-insensitive, like every other value it takes"
);
assert_eq!(
ImportPolicy::parse("default").unwrap_err().to_string(),
"invalid input: unknown --triage-policy 'default' — use entry or remote",
"the default is what an absent flag means, not a value it can take"
);
assert!(ImportPolicy::parse("triage").is_err());
let mut seen: BTreeMap<&str, ImportPolicy> = BTreeMap::new();
for raw in ["entry", "remote"] {
seen.insert(raw, ImportPolicy::parse(raw).unwrap());
}
assert_eq!(seen.len(), 2);
}
}