orchid-cli 0.1.0

Task-file orchestration helper for coordinating scoped agent work.
Documentation
use chrono::{DateTime, Local, SecondsFormat, TimeDelta, Utc};
use serde_json::{Map, Value};

pub(crate) const DEFAULT_STALE_AFTER: &str = "30m";

#[derive(Debug, Copy, Clone)]
pub(crate) enum ErrorCode {
    ActiveLeaseCloseRequiresForce,
    CleanupModeRequired,
    HumanCheckpoint,
    InactiveSpec,
    InvalidDuration,
    InvalidJson,
    InvalidSpecId,
    LeaseNotFound,
    ParallelNotConfirmed,
    PathOutsideRepo,
    InvalidReportStatus,
    ReportMissingLeaseId,
    RuntimeLockBusy,
    ScopeConflict,
    ScopeRequired,
    ScopeSelectorConflict,
    SerialBlocked,
    SpecManual,
    SpecNotFound,
    TaskAlreadyLeased,
    TaskIdRequired,
    TaskNotTodo,
}

impl ErrorCode {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            ErrorCode::ActiveLeaseCloseRequiresForce => "active_lease_close_requires_force",
            ErrorCode::CleanupModeRequired => "cleanup_mode_required",
            ErrorCode::HumanCheckpoint => "human_checkpoint",
            ErrorCode::InactiveSpec => "inactive_spec",
            ErrorCode::InvalidDuration => "invalid_duration",
            ErrorCode::InvalidJson => "invalid_json",
            ErrorCode::InvalidSpecId => "invalid_spec_id",
            ErrorCode::LeaseNotFound => "lease_not_found",
            ErrorCode::ParallelNotConfirmed => "parallel_not_confirmed",
            ErrorCode::PathOutsideRepo => "path_outside_repo",
            ErrorCode::InvalidReportStatus => "invalid_report_status",
            ErrorCode::ReportMissingLeaseId => "report_missing_lease_id",
            ErrorCode::RuntimeLockBusy => "runtime_lock_busy",
            ErrorCode::ScopeConflict => "scope_conflict",
            ErrorCode::ScopeRequired => "scope_required",
            ErrorCode::ScopeSelectorConflict => "scope_selector_conflict",
            ErrorCode::SerialBlocked => "serial_blocked",
            ErrorCode::SpecManual => "spec_manual",
            ErrorCode::SpecNotFound => "spec_not_found",
            ErrorCode::TaskAlreadyLeased => "task_already_leased",
            ErrorCode::TaskIdRequired => "task_id_required",
            ErrorCode::TaskNotTodo => "task_not_todo",
        }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct OrchError {
    pub(crate) message: String,
    pub(crate) code: String,
    pub(crate) details: Map<String, Value>,
}

impl OrchError {
    pub(crate) fn new(message: impl Into<String>) -> Self {
        let message = message.into();
        Self {
            code: error_code(&message),
            message,
            details: Map::new(),
        }
    }

    pub(crate) fn with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            code: code.into(),
            details: Map::new(),
        }
    }

    pub(crate) fn coded(message: impl Into<String>, code: ErrorCode) -> Self {
        Self::with_code(message, code.as_str())
    }

    pub(crate) fn detail(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
        self.details.insert(key.into(), value.into());
        self
    }
}

impl From<std::io::Error> for OrchError {
    fn from(error: std::io::Error) -> Self {
        OrchError::new("I/O error").detail("message", error.to_string())
    }
}

pub(crate) type OrchResult<T> = Result<T, OrchError>;

pub(crate) fn error_code(message: &str) -> String {
    let mut out = String::new();
    let mut last_was_sep = true;
    for ch in message.chars().flat_map(|ch| ch.to_lowercase()) {
        if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
            out.push(ch);
            last_was_sep = false;
        } else if !last_was_sep {
            out.push('_');
            last_was_sep = true;
        }
    }
    while out.ends_with('_') {
        out.pop();
    }
    if out.is_empty() {
        "orch_error".to_string()
    } else {
        out
    }
}

pub(crate) fn now_iso() -> String {
    Local::now().to_rfc3339_opts(SecondsFormat::Secs, false)
}

pub(crate) fn utc_now() -> DateTime<Utc> {
    Utc::now()
}

pub(crate) fn json_ok() -> Map<String, Value> {
    let mut map = Map::new();
    map.insert("ok".to_string(), Value::Bool(true));
    map
}

pub(crate) fn json_fail(error: &str, code: Option<&str>) -> Map<String, Value> {
    let mut map = Map::new();
    map.insert("ok".to_string(), Value::Bool(false));
    map.insert("error".to_string(), Value::String(error.to_string()));
    map.insert(
        "code".to_string(),
        Value::String(
            code.map(str::to_string)
                .unwrap_or_else(|| error_code(error)),
        ),
    );
    map
}

pub(crate) fn emit(payload: &Map<String, Value>, pretty: bool) {
    let value = Value::Object(payload.clone());
    if pretty {
        println!(
            "{}",
            serde_json::to_string_pretty(&value).expect("json encoding")
        );
    } else {
        println!("{}", serde_json::to_string(&value).expect("json encoding"));
    }
}

pub(crate) fn string_list(value: Option<&Value>) -> Vec<String> {
    match value {
        None | Some(Value::Null) => Vec::new(),
        Some(Value::String(raw)) if raw.is_empty() => Vec::new(),
        Some(Value::String(raw)) => vec![raw.clone()],
        Some(Value::Array(items)) => items
            .iter()
            .filter_map(value_to_string)
            .filter(|item| !item.is_empty())
            .collect(),
        Some(other) => value_to_string(other).into_iter().collect(),
    }
}

pub(crate) fn value_to_string(value: &Value) -> Option<String> {
    match value {
        Value::Null => None,
        Value::String(raw) => Some(raw.clone()),
        Value::Bool(raw) => Some(raw.to_string()),
        Value::Number(raw) => Some(raw.to_string()),
        other => Some(other.to_string()),
    }
}

pub(crate) fn parse_iso_datetime(value: Option<&Value>) -> Option<DateTime<Utc>> {
    let raw = match value {
        Some(Value::String(raw)) => raw.as_str(),
        Some(other) => {
            return DateTime::parse_from_rfc3339(&other.to_string())
                .ok()
                .map(|d| d.with_timezone(&Utc))
        }
        None => return None,
    };
    if raw.is_empty() {
        return None;
    }
    DateTime::parse_from_rfc3339(raw)
        .ok()
        .map(|stamp| stamp.with_timezone(&Utc))
}

pub(crate) fn parse_iso_datetime_str(raw: &str) -> Option<DateTime<Utc>> {
    if raw.is_empty() {
        return None;
    }
    DateTime::parse_from_rfc3339(raw)
        .ok()
        .map(|stamp| stamp.with_timezone(&Utc))
}

pub(crate) fn elapsed_seconds(value: Option<&Value>, now: DateTime<Utc>) -> i64 {
    parse_iso_datetime(value)
        .map(|stamp| (now - stamp).num_seconds().max(0))
        .unwrap_or(0)
}

pub(crate) fn parse_duration(value: &str) -> OrchResult<TimeDelta> {
    let raw = value.trim();
    if raw.len() < 2 {
        return Err(
            OrchError::coded("invalid duration", ErrorCode::InvalidDuration)
                .detail("duration", value),
        );
    }
    let (amount, unit) = raw.split_at(raw.len() - 1);
    let amount: i64 = amount.parse().map_err(|_| {
        OrchError::coded("invalid duration", ErrorCode::InvalidDuration).detail("duration", value)
    })?;
    match unit {
        "s" => Ok(TimeDelta::seconds(amount)),
        "m" => Ok(TimeDelta::minutes(amount)),
        "h" => Ok(TimeDelta::hours(amount)),
        "d" => Ok(TimeDelta::days(amount)),
        _ => Err(
            OrchError::coded("invalid duration", ErrorCode::InvalidDuration)
                .detail("duration", value),
        ),
    }
}

pub(crate) fn insert(map: &mut Map<String, Value>, key: &str, value: impl Into<Value>) {
    map.insert(key.to_string(), value.into());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn duration_parser_accepts_supported_units() {
        assert_eq!(parse_duration("30s").unwrap().num_seconds(), 30);
        assert_eq!(parse_duration("15m").unwrap().num_minutes(), 15);
        assert_eq!(parse_duration("2h").unwrap().num_hours(), 2);
        assert_eq!(parse_duration("3d").unwrap().num_days(), 3);
    }

    #[test]
    fn duration_parser_uses_stable_error_code() {
        let err = parse_duration("soon").unwrap_err();
        assert_eq!(err.message, "invalid duration");
        assert_eq!(err.code, ErrorCode::InvalidDuration.as_str());
        assert_eq!(err.details["duration"], "soon");
    }
}