use std::collections::BTreeSet;
use ito_domain::backend::BackendChangeReader;
use ito_domain::changes::{
Change, ChangeLifecycleFilter, ChangeRepository as DomainChangeRepository, ChangeSummary,
ChangeTargetResolution, ResolveTargetOptions, parse_change_id, parse_module_id,
};
use ito_domain::errors::DomainResult;
use regex::Regex;
pub struct BackendChangeRepository<R: BackendChangeReader> {
reader: R,
}
impl<R: BackendChangeReader> BackendChangeRepository<R> {
pub fn new(reader: R) -> Self {
Self { reader }
}
}
impl<R: BackendChangeReader> BackendChangeRepository<R> {
fn split_canonical_change_id<'b>(&self, name: &'b str) -> Option<(String, String, &'b str)> {
let (module_id, change_num) = parse_change_id(name)?;
let slug = name.split_once('_').map(|(_id, s)| s).unwrap_or("");
Some((module_id, change_num, slug))
}
fn tokenize_query(&self, input: &str) -> Vec<String> {
let mut out = Vec::new();
for part in input.split_whitespace() {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
out.push(trimmed.to_lowercase());
}
out
}
fn normalized_slug_text(&self, slug: &str) -> String {
let mut out = String::new();
for ch in slug.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else {
out.push(' ');
}
}
out
}
fn slug_matches_tokens(&self, slug: &str, tokens: &[String]) -> bool {
if tokens.is_empty() {
return false;
}
let text = self.normalized_slug_text(slug);
for token in tokens {
if !text.contains(token) {
return false;
}
}
true
}
fn is_numeric_module_selector(&self, input: &str) -> bool {
let trimmed = input.trim();
!trimmed.is_empty() && trimmed.chars().all(|ch| ch.is_ascii_digit())
}
fn extract_two_numbers_as_change_id(&self, input: &str) -> Option<(String, String)> {
let re = Regex::new(r"\d+").ok()?;
let mut parts: Vec<&str> = Vec::new();
for m in re.find_iter(input) {
parts.push(m.as_str());
if parts.len() > 2 {
return None;
}
}
if parts.len() != 2 {
return None;
}
let parsed = format!("{}-{}", parts[0], parts[1]);
parse_change_id(&parsed)
}
}
impl<R: BackendChangeReader> DomainChangeRepository for BackendChangeRepository<R> {
fn resolve_target_with_options(
&self,
input: &str,
options: ResolveTargetOptions,
) -> ChangeTargetResolution {
let Ok(summaries) = self.reader.list_changes(options.lifecycle) else {
return ChangeTargetResolution::NotFound;
};
let mut names: Vec<String> = summaries.iter().map(|s| s.id.clone()).collect();
names.sort();
names.dedup();
if names.is_empty() {
return ChangeTargetResolution::NotFound;
}
let input = input.trim();
if input.is_empty() {
return ChangeTargetResolution::NotFound;
}
if names.iter().any(|name| name == input) {
return ChangeTargetResolution::Unique(input.to_string());
}
let mut numeric_matches: BTreeSet<String> = BTreeSet::new();
let numeric_selector =
parse_change_id(input).or_else(|| self.extract_two_numbers_as_change_id(input));
if let Some((module_id, change_num)) = numeric_selector {
let numeric_prefix = format!("{module_id}-{change_num}");
let with_separator = format!("{numeric_prefix}_");
for name in &names {
if name == &numeric_prefix || name.starts_with(&with_separator) {
numeric_matches.insert(name.clone());
}
}
if !numeric_matches.is_empty() {
let numeric_matches: Vec<String> = numeric_matches.into_iter().collect();
if numeric_matches.len() == 1 {
return ChangeTargetResolution::Unique(numeric_matches[0].clone());
}
return ChangeTargetResolution::Ambiguous(numeric_matches);
}
}
if let Some((module, query)) = input.split_once(':') {
let query = query.trim();
if !query.is_empty() {
let module_id = parse_module_id(module);
let tokens = self.tokenize_query(query);
let mut scoped_matches: BTreeSet<String> = BTreeSet::new();
for name in &names {
let Some((name_module, _name_change, slug)) =
self.split_canonical_change_id(name)
else {
continue;
};
if name_module != module_id {
continue;
}
if self.slug_matches_tokens(slug, &tokens) {
scoped_matches.insert(name.clone());
}
}
if scoped_matches.is_empty() {
return ChangeTargetResolution::NotFound;
}
let scoped_matches: Vec<String> = scoped_matches.into_iter().collect();
if scoped_matches.len() == 1 {
return ChangeTargetResolution::Unique(scoped_matches[0].clone());
}
return ChangeTargetResolution::Ambiguous(scoped_matches);
}
}
if self.is_numeric_module_selector(input) {
let module_id = parse_module_id(input);
let mut module_matches: BTreeSet<String> = BTreeSet::new();
for name in &names {
let Some((name_module, _name_change, _slug)) = self.split_canonical_change_id(name)
else {
continue;
};
if name_module == module_id {
module_matches.insert(name.clone());
}
}
if !module_matches.is_empty() {
let module_matches: Vec<String> = module_matches.into_iter().collect();
if module_matches.len() == 1 {
return ChangeTargetResolution::Unique(module_matches[0].clone());
}
return ChangeTargetResolution::Ambiguous(module_matches);
}
}
let mut matches: BTreeSet<String> = BTreeSet::new();
for name in &names {
if name.starts_with(input) {
matches.insert(name.clone());
}
}
if matches.is_empty() {
let tokens = self.tokenize_query(input);
for name in &names {
let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
continue;
};
if self.slug_matches_tokens(slug, &tokens) {
matches.insert(name.clone());
}
}
}
if matches.is_empty() {
return ChangeTargetResolution::NotFound;
}
let matches: Vec<String> = matches.into_iter().collect();
if matches.len() == 1 {
return ChangeTargetResolution::Unique(matches[0].clone());
}
ChangeTargetResolution::Ambiguous(matches)
}
fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
let Ok(summaries) = self.reader.list_changes(ChangeLifecycleFilter::Active) else {
return Vec::new();
};
let ids: Vec<String> = summaries.iter().map(|s| s.id.clone()).collect();
ito_common::match_::nearest_matches(input, &ids, max)
}
fn exists(&self, id: &str) -> bool {
self.exists_with_filter(id, ChangeLifecycleFilter::Active)
}
fn exists_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> bool {
self.reader.get_change(id, filter).is_ok()
}
fn get_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
self.reader.get_change(id, filter)
}
fn list_with_filter(&self, filter: ChangeLifecycleFilter) -> DomainResult<Vec<ChangeSummary>> {
self.reader.list_changes(filter)
}
fn list_by_module_with_filter(
&self,
module_id: &str,
filter: ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
let normalized_id = parse_module_id(module_id);
let all = self.reader.list_changes(filter)?;
let mut filtered = Vec::new();
for s in all {
if s.module_id.as_deref() == Some(&normalized_id) {
filtered.push(s);
}
}
Ok(filtered)
}
fn list_incomplete_with_filter(
&self,
filter: ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
let all = self.reader.list_changes(filter)?;
let filtered = all
.into_iter()
.filter(|s| s.completed_tasks < s.total_tasks || s.total_tasks == 0)
.collect();
Ok(filtered)
}
fn list_complete_with_filter(
&self,
filter: ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
let all = self.reader.list_changes(filter)?;
let filtered = all
.into_iter()
.filter(|s| s.total_tasks > 0 && s.completed_tasks == s.total_tasks)
.collect();
Ok(filtered)
}
fn get_summary_with_filter(
&self,
id: &str,
filter: ChangeLifecycleFilter,
) -> DomainResult<ChangeSummary> {
let all = self.reader.list_changes(filter)?;
for summary in all {
if summary.id == id {
return Ok(summary);
}
}
Err(ito_domain::errors::DomainError::not_found("change", id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use ito_domain::tasks::TasksParseResult;
struct FakeReader {
changes: Vec<ChangeSummary>,
full: Vec<Change>,
}
impl FakeReader {
fn new(changes: Vec<ChangeSummary>, full: Vec<Change>) -> Self {
Self { changes, full }
}
}
impl BackendChangeReader for FakeReader {
fn list_changes(
&self,
_filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
Ok(self.changes.clone())
}
fn get_change(
&self,
change_id: &str,
_filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Change> {
for c in &self.full {
if c.id == change_id {
return Ok(c.clone());
}
}
Err(ito_domain::errors::DomainError::not_found(
"change", change_id,
))
}
}
fn make_summary(id: &str, completed: u32, total: u32) -> ChangeSummary {
ChangeSummary {
id: id.to_string(),
module_id: None,
sub_module_id: None,
completed_tasks: completed,
shelved_tasks: 0,
in_progress_tasks: 0,
pending_tasks: total - completed,
total_tasks: total,
last_modified: Utc::now(),
has_proposal: true,
has_design: false,
has_specs: true,
has_tasks: true,
orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
}
}
fn make_change(id: &str) -> Change {
Change {
id: id.to_string(),
module_id: None,
sub_module_id: None,
path: std::path::PathBuf::from("/fake"),
proposal: Some("# Proposal".to_string()),
design: None,
specs: vec![],
tasks: TasksParseResult::empty(),
orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
last_modified: Utc::now(),
}
}
#[test]
fn list_returns_all_changes() {
let summaries = vec![
make_summary("001-01_a", 0, 2),
make_summary("001-02_b", 1, 2),
];
let reader = FakeReader::new(summaries.clone(), vec![]);
let repo = BackendChangeRepository::new(reader);
let result = repo.list().unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn resolve_target_exact_match() {
let summaries = vec![make_summary("001-01_a", 0, 2)];
let reader = FakeReader::new(summaries, vec![]);
let repo = BackendChangeRepository::new(reader);
let resolution = DomainChangeRepository::resolve_target(&repo, "001-01_a");
assert!(matches!(resolution, ChangeTargetResolution::Unique(id) if id == "001-01_a"));
}
#[test]
fn resolve_target_prefix_match() {
let summaries = vec![make_summary("001-01_something", 0, 2)];
let reader = FakeReader::new(summaries, vec![]);
let repo = BackendChangeRepository::new(reader);
let resolution = DomainChangeRepository::resolve_target(&repo, "001-01");
assert!(
matches!(resolution, ChangeTargetResolution::Unique(id) if id == "001-01_something")
);
}
#[test]
fn resolve_target_ambiguous() {
let summaries = vec![
make_summary("001-01_a", 0, 2),
make_summary("001-01_b", 0, 2),
];
let reader = FakeReader::new(summaries, vec![]);
let repo = BackendChangeRepository::new(reader);
let resolution = DomainChangeRepository::resolve_target(&repo, "001-01");
assert!(matches!(resolution, ChangeTargetResolution::Ambiguous(_)));
}
#[test]
fn resolve_target_not_found() {
let summaries = vec![make_summary("001-01_a", 0, 2)];
let reader = FakeReader::new(summaries, vec![]);
let repo = BackendChangeRepository::new(reader);
let resolution = DomainChangeRepository::resolve_target(&repo, "999");
assert!(matches!(resolution, ChangeTargetResolution::NotFound));
}
#[test]
fn get_delegates_to_reader() {
let change = make_change("001-01_a");
let reader = FakeReader::new(vec![], vec![change]);
let repo = BackendChangeRepository::new(reader);
let result = DomainChangeRepository::get(&repo, "001-01_a").unwrap();
assert_eq!(result.id, "001-01_a");
}
#[test]
fn list_incomplete_filters_correctly() {
let summaries = vec![
make_summary("001-01_a", 2, 2),
make_summary("001-02_b", 1, 2),
];
let reader = FakeReader::new(summaries, vec![]);
let repo = BackendChangeRepository::new(reader);
let incomplete = DomainChangeRepository::list_incomplete(&repo).unwrap();
assert_eq!(incomplete.len(), 1);
assert_eq!(incomplete[0].id, "001-02_b");
}
#[test]
fn list_complete_filters_correctly() {
let summaries = vec![
make_summary("001-01_a", 2, 2),
make_summary("001-02_b", 1, 2),
];
let reader = FakeReader::new(summaries, vec![]);
let repo = BackendChangeRepository::new(reader);
let complete = DomainChangeRepository::list_complete(&repo).unwrap();
assert_eq!(complete.len(), 1);
assert_eq!(complete[0].id, "001-01_a");
}
}