ito-domain 0.1.32

Domain models and repositories for Ito
Documentation
//! Change domain models and repository.
//!
//! This module provides domain models for Ito changes and a repository
//! for loading and querying change data.

mod mutations;
mod repository;

pub use mutations::{
    ChangeArtifactKind, ChangeArtifactMutationError, ChangeArtifactMutationResult,
    ChangeArtifactMutationService, ChangeArtifactMutationServiceResult, ChangeArtifactRef,
};
pub use repository::{
    ChangeLifecycleFilter, ChangeRepository, ChangeTargetResolution, ResolveTargetOptions,
};

use chrono::{DateTime, Utc};
use std::path::PathBuf;

use crate::tasks::{ProgressInfo, TasksParseResult};

/// A specification within a change.
#[derive(Debug, Clone)]
pub struct Spec {
    /// Spec name (directory name under specs/)
    pub name: String,
    /// Spec content (raw markdown)
    pub content: String,
}

/// Status of a change based on task completion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeStatus {
    /// No tasks defined
    NoTasks,
    /// Some tasks incomplete
    InProgress,
    /// All tasks complete
    Complete,
}

/// Work status of a change.
///
/// This is a derived status intended for UX and filtering. It is NOT a persisted
/// lifecycle state.
///
/// Semantics:
/// - `Draft`: missing required planning artifacts (proposal + specs + tasks)
/// - `Ready`: planning artifacts exist and there is remaining work, with no in-progress tasks
/// - `InProgress`: at least one task is in-progress
/// - `Paused`: no remaining work, but at least one task is shelved (i.e. all tasks are done or shelved)
/// - `Complete`: all tasks are complete (shelved tasks do NOT count as complete)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeWorkStatus {
    /// Missing required planning artifacts (proposal + specs + tasks).
    Draft,
    /// Ready to start work (planning artifacts exist, remaining work, nothing in-progress).
    Ready,
    /// At least one task is in-progress.
    InProgress,
    /// No remaining work, but at least one task is shelved.
    ///
    /// This distinguishes "we're finished but chose to shelve something" from `Complete`.
    Paused,
    /// All tasks complete.
    ///
    /// Note: shelved tasks do NOT count as complete.
    Complete,
}

/// Per-change orchestration metadata.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChangeOrchestrateMetadata {
    /// Canonical change IDs that must complete before this change is dispatched.
    pub depends_on: Vec<String>,
    /// Optional gate order override for this change.
    pub preferred_gates: Vec<String>,
}

impl std::fmt::Display for ChangeWorkStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ChangeWorkStatus::Draft => write!(f, "draft"),
            ChangeWorkStatus::Ready => write!(f, "ready"),
            ChangeWorkStatus::InProgress => write!(f, "in-progress"),
            ChangeWorkStatus::Paused => write!(f, "paused"),
            ChangeWorkStatus::Complete => write!(f, "complete"),
        }
    }
}

impl std::fmt::Display for ChangeStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ChangeStatus::NoTasks => write!(f, "no-tasks"),
            ChangeStatus::InProgress => write!(f, "in-progress"),
            ChangeStatus::Complete => write!(f, "complete"),
        }
    }
}

/// Full change with all artifacts loaded.
#[derive(Debug, Clone)]
pub struct Change {
    /// Change identifier (e.g., "005-01_my-change" or "005.01-03_my-change")
    pub id: String,
    /// Module ID extracted from the change ID (e.g., "005")
    pub module_id: Option<String>,
    /// Sub-module ID in canonical `NNN.SS` form when the change belongs to a sub-module.
    ///
    /// `None` for changes that use the legacy `NNN-NN_name` format without a sub-module.
    pub sub_module_id: Option<String>,
    /// Path to the change directory
    pub path: PathBuf,
    /// Proposal content (raw markdown)
    pub proposal: Option<String>,
    /// Design content (raw markdown)
    pub design: Option<String>,
    /// Specifications
    pub specs: Vec<Spec>,
    /// Parsed tasks
    pub tasks: TasksParseResult,
    /// Per-change orchestration metadata.
    pub orchestrate: ChangeOrchestrateMetadata,
    /// Last modification time of any artifact
    pub last_modified: DateTime<Utc>,
}

impl Change {
    /// Get the status of this change based on task completion.
    pub fn status(&self) -> ChangeStatus {
        let progress = &self.tasks.progress;
        if progress.total == 0 {
            ChangeStatus::NoTasks
        } else if progress.complete >= progress.total {
            ChangeStatus::Complete
        } else {
            ChangeStatus::InProgress
        }
    }

    /// Derived work status for UX and filtering.
    pub fn work_status(&self) -> ChangeWorkStatus {
        let ProgressInfo {
            total,
            complete,
            shelved,
            in_progress,
            pending,
            remaining: _,
        } = self.tasks.progress;

        // Planning artifacts required to start work.
        let has_planning_artifacts = self.proposal.is_some() && !self.specs.is_empty() && total > 0;
        if !has_planning_artifacts {
            return ChangeWorkStatus::Draft;
        }

        if complete == total {
            return ChangeWorkStatus::Complete;
        }
        if in_progress > 0 {
            return ChangeWorkStatus::InProgress;
        }

        let done_or_shelved = complete + shelved;
        if pending == 0 && shelved > 0 && done_or_shelved == total {
            return ChangeWorkStatus::Paused;
        }

        ChangeWorkStatus::Ready
    }

    /// Check if all required artifacts are present.
    pub fn artifacts_complete(&self) -> bool {
        self.proposal.is_some()
            && self.design.is_some()
            && !self.specs.is_empty()
            && self.tasks.progress.total > 0
    }

    /// Get task progress as (completed, total).
    pub fn task_progress(&self) -> (u32, u32) {
        (
            self.tasks.progress.complete as u32,
            self.tasks.progress.total as u32,
        )
    }

    /// Get the progress info for this change.
    pub fn progress(&self) -> &ProgressInfo {
        &self.tasks.progress
    }
}

/// Lightweight change summary for listings.
#[derive(Debug, Clone)]
pub struct ChangeSummary {
    /// Change identifier
    pub id: String,
    /// Module ID extracted from the change ID
    pub module_id: Option<String>,
    /// Sub-module ID in canonical `NNN.SS` form when the change belongs to a sub-module.
    ///
    /// `None` for changes that use the legacy `NNN-NN_name` format without a sub-module.
    pub sub_module_id: Option<String>,
    /// Number of completed tasks
    pub completed_tasks: u32,
    /// Number of shelved tasks (enhanced tasks only)
    pub shelved_tasks: u32,
    /// Number of in-progress tasks
    pub in_progress_tasks: u32,
    /// Number of pending tasks
    pub pending_tasks: u32,
    /// Total number of tasks
    pub total_tasks: u32,
    /// Last modification time
    pub last_modified: DateTime<Utc>,
    /// Whether proposal.md exists
    pub has_proposal: bool,
    /// Whether design.md exists
    pub has_design: bool,
    /// Whether specs/ directory has content
    pub has_specs: bool,
    /// Whether tasks.md exists and has tasks
    pub has_tasks: bool,
    /// Per-change orchestration metadata.
    pub orchestrate: ChangeOrchestrateMetadata,
}

impl ChangeSummary {
    /// Get the status of this change based on task counts.
    pub fn status(&self) -> ChangeStatus {
        if self.total_tasks == 0 {
            ChangeStatus::NoTasks
        } else if self.completed_tasks >= self.total_tasks {
            ChangeStatus::Complete
        } else {
            ChangeStatus::InProgress
        }
    }

    /// Derived work status for UX and filtering.
    pub fn work_status(&self) -> ChangeWorkStatus {
        let has_planning_artifacts = self.has_proposal && self.has_specs && self.has_tasks;
        if !has_planning_artifacts {
            return ChangeWorkStatus::Draft;
        }

        if self.total_tasks > 0 && self.completed_tasks == self.total_tasks {
            return ChangeWorkStatus::Complete;
        }
        if self.in_progress_tasks > 0 {
            return ChangeWorkStatus::InProgress;
        }

        let done_or_shelved = self.completed_tasks + self.shelved_tasks;
        if self.pending_tasks == 0 && self.shelved_tasks > 0 && done_or_shelved == self.total_tasks
        {
            return ChangeWorkStatus::Paused;
        }

        ChangeWorkStatus::Ready
    }

    /// Check if this change is ready for implementation.
    ///
    /// A change is "ready" when it has all required planning artifacts and has remaining work
    /// with no in-progress tasks.
    pub fn is_ready(&self) -> bool {
        self.work_status() == ChangeWorkStatus::Ready
    }
}

/// Extract module ID from a change ID.
///
/// Handles both the legacy `NNN-NN_name` format and the sub-module
/// `NNN.SS-NN_name` format. Always returns only the parent module number.
///
/// - `005-01_my-change` -> `005`
/// - `5-1_whatever` -> `005`
/// - `1-000002` -> `001`
/// - `024.01-03_foo` -> `024`
pub fn extract_module_id(change_id: &str) -> Option<String> {
    let parts: Vec<&str> = change_id.split('-').collect();
    if parts.len() >= 2 {
        // Strip any sub-module component (e.g., "024.01" -> "024").
        let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
        Some(normalize_id(module_part, 3))
    } else {
        None
    }
}

/// Extract the sub-module ID from a change ID in `NNN.SS-NN_name` format.
///
/// Returns `Some("NNN.SS")` for sub-module changes, `None` for legacy
/// `NNN-NN_name` changes.
///
/// - `024.01-03_foo` -> `Some("024.01")`
/// - `005-01_my-change` -> `None`
pub fn extract_sub_module_id(change_id: &str) -> Option<String> {
    // A sub-module change has a dot before the first hyphen.
    let prefix = change_id.split('-').next()?;
    if !prefix.contains('.') {
        return None;
    }
    // Normalize: "24.1" -> "024.01" via the common parser.
    ito_common::id::parse_sub_module_id(prefix)
        .map(|p| p.sub_module_id.as_str().to_string())
        .ok()
}

/// Normalize an ID to a fixed width with zero-padding.
///
/// - `"5"` with width 3 -> `"005"`
/// - `"005"` with width 3 -> `"005"`
/// - `"0005"` with width 3 -> `"005"` (strips leading zeros beyond width)
pub fn normalize_id(id: &str, width: usize) -> String {
    // Parse as number to strip leading zeros, then reformat
    let num: u32 = id.parse().unwrap_or(0);
    format!("{:0>width$}", num, width = width)
}

/// Parse a change identifier and return the normalized module ID and change number.
///
/// Handles both legacy and sub-module formats:
/// - `005-01_my-change` → `("005", "01")`
/// - `5-1_whatever` → `("005", "01")`
/// - `1-2` → `("001", "02")`
/// - `001-000002_foo` → `("001", "02")`
/// - `024.01-03_foo` → `("024", "03")`
pub fn parse_change_id(input: &str) -> Option<(String, String)> {
    // Remove the name suffix if present (everything after underscore)
    let id_part = input.split('_').next().unwrap_or(input);

    let parts: Vec<&str> = id_part.split('-').collect();
    if parts.len() >= 2 {
        // Strip any sub-module component (e.g., "024.01" → "024").
        let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
        let module_id = normalize_id(module_part, 3);
        let change_num = normalize_id(parts[1], 2);
        Some((module_id, change_num))
    } else {
        None
    }
}

/// Parse a module identifier and return the normalized module ID.
///
/// Handles various formats:
/// - `005` -> `"005"`
/// - `5` -> `"005"`
/// - `005_dev-tooling` -> `"005"`
/// - `5_dev-tooling` -> `"005"`
pub fn parse_module_id(input: &str) -> String {
    // Remove the name suffix if present (everything after underscore)
    let id_part = input.split('_').next().unwrap_or(input);
    normalize_id(id_part, 3)
}

#[cfg(test)]
mod changes_tests;