cflx 0.6.153

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
use crate::dependency_targets::{self, DependencyTargetClass};
use crate::openspec_cmd::model::{
    DependencyListStatus, DependencyStatusContext, DependencyStatusInfo,
};
use std::collections::HashSet;
use std::fs;
use std::path::Path;

impl DependencyStatusContext {
    pub(super) fn from_workspace(root_dir: &Path) -> Self {
        Self {
            active_ids: collect_active_change_ids_from_root(root_dir),
            in_flight_ids: collect_in_flight_change_ids_from_root(root_dir),
            archived_ids: dependency_targets::collect_archived_change_ids(root_dir),
            rejected_ids: dependency_targets::collect_rejected_change_ids(root_dir),
        }
    }

    pub(super) fn statuses_for(&self, dependencies: &[String]) -> Vec<DependencyStatusInfo> {
        dependencies
            .iter()
            .map(|dependency| DependencyStatusInfo {
                id: dependency.clone(),
                status: self.status_for(dependency),
            })
            .collect()
    }

    pub(super) fn status_for(&self, dependency: &str) -> DependencyListStatus {
        match dependency_targets::classify_dependency_target(
            dependency,
            self.active_ids.iter().map(String::as_str),
            self.in_flight_ids.iter().map(String::as_str),
            self.active_ids.iter().map(String::as_str),
            &self.archived_ids,
            &self.rejected_ids,
        ) {
            DependencyTargetClass::Archived => DependencyListStatus::Done,
            DependencyTargetClass::InFlight => DependencyListStatus::Running,
            DependencyTargetClass::Queued | DependencyTargetClass::ActiveButNotQueued => {
                DependencyListStatus::Pending
            }
            DependencyTargetClass::Rejected => DependencyListStatus::Rejected,
            DependencyTargetClass::Error => {
                unreachable!("dependency list classification cannot produce terminal-error state")
            }
            DependencyTargetClass::Missing => DependencyListStatus::Missing,
        }
    }
}

#[derive(Debug, Clone)]
pub(super) struct DependencyTargetDiagnostic {
    pub(super) classification: DependencyTargetClass,
    pub(super) message: String,
}

pub(super) fn classify_proposal_dependency_targets(
    change_id: &str,
    proposal_file: &Path,
) -> Vec<DependencyTargetDiagnostic> {
    let proposal_metadata = crate::openspec::parse_proposal_metadata_from_file(proposal_file);
    if proposal_metadata.dependencies.is_empty() {
        return Vec::new();
    }

    let active_ids = collect_active_change_ids();
    let in_flight_ids = collect_in_flight_change_ids();
    let archived_ids = collect_archived_change_ids();
    let rejected_ids = dependency_targets::collect_rejected_change_ids(Path::new("."));

    proposal_metadata
        .dependencies
        .into_iter()
        .map(|dependency| {
            let classification = dependency_targets::classify_dependency_target(
                &dependency,
                active_ids.iter().map(String::as_str),
                in_flight_ids.iter().map(String::as_str),
                active_ids.iter().map(String::as_str),
                &archived_ids,
                &rejected_ids,
            );

            let message = match classification {
                DependencyTargetClass::Queued => format!(
                    "{}: proposal dependency '{}' classified as queued (active change)",
                    change_id, dependency
                ),
                DependencyTargetClass::InFlight => format!(
                    "{}: proposal dependency '{}' classified as in-flight (workspace execution marker)",
                    change_id, dependency
                ),
                DependencyTargetClass::ActiveButNotQueued => format!(
                    "{}: proposal dependency '{}' classified as active-but-not-queued (active change exists outside the queued set)",
                    change_id, dependency
                ),
                DependencyTargetClass::Archived => format!(
                    "{}: proposal dependency '{}' classified as archived dependency reference (warning: metadata should be reviewed after archive)",
                    change_id, dependency
                ),
                DependencyTargetClass::Rejected => format!(
                    "{}: proposal dependency '{}' is invalid: classified as rejected dependency target",
                    change_id, dependency
                ),
                DependencyTargetClass::Error => unreachable!(
                    "proposal dependency classification cannot produce terminal-error state"
                ),
                DependencyTargetClass::Missing => format!(
                    "{}: proposal dependency '{}' is invalid: not found in active, in-flight, archived, or rejected change targets",
                    change_id, dependency
                ),
            };

            DependencyTargetDiagnostic {
                classification,
                message,
            }
        })
        .collect()
}

pub(super) fn collect_active_change_ids() -> HashSet<String> {
    collect_active_change_ids_from_root(Path::new("."))
}

pub(super) fn collect_active_change_ids_from_root(root_dir: &Path) -> HashSet<String> {
    let changes_dir = root_dir.join("openspec/changes");
    let Ok(entries) = fs::read_dir(changes_dir) else {
        return HashSet::new();
    };

    entries
        .filter_map(|entry| entry.ok())
        .filter_map(|entry| {
            let path = entry.path();
            if !path.is_dir() {
                return None;
            }
            let name = entry.file_name().to_string_lossy().to_string();
            if name == "archive" || name.starts_with('.') {
                return None;
            }
            if !path.join("proposal.md").exists() || path.join("REJECTED.md").exists() {
                return None;
            }
            Some(name)
        })
        .collect()
}

pub(super) fn collect_in_flight_change_ids() -> HashSet<String> {
    collect_in_flight_change_ids_from_root(Path::new("."))
}

pub(super) fn collect_in_flight_change_ids_from_root(root_dir: &Path) -> HashSet<String> {
    let state_file = root_dir.join(".conflux-inflight");
    let Ok(content) = fs::read_to_string(state_file) else {
        return HashSet::new();
    };

    content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

pub(super) fn collect_archived_change_ids() -> HashSet<String> {
    dependency_targets::collect_archived_change_ids(Path::new("."))
}