qctl 0.0.1

Control in-repo YAML work queues
Documentation
use crate::cli::{AddArgs, ArchiveArgs, InitArgs};
use crate::ledger::{load, next_id, resolve_path};
use anyhow::{Context, Result, bail, ensure};
use serde_yml::{Mapping, Value};
use std::fs;
use std::path::Path;
use time::OffsetDateTime;
use time::macros::format_description;

pub fn init(args: &InitArgs) -> Result<()> {
    let prefix = args.prefix.to_ascii_uppercase();
    ensure!(
        valid_prefix(&prefix),
        "prefix must match [A-Z][A-Z0-9]{{1,7}}"
    );
    let path = resolve_path(&args.ledger);
    if path.exists() && !args.force {
        bail!("{} already exists (pass --force)", path.display());
    }
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)?;
    }
    let body = format!(
        "# yaml-language-server: $schema=https://raw.githubusercontent.com/victor-software-house/qctl/main/schema/tasks.schema.json\nschema_version: 1\nprefix: {prefix}\nactive: null\nqueue: []\narchive: []\nhorizon: []\n"
    );
    fs::write(&path, body).with_context(|| path.display().to_string())?;
    println!("wrote {}", path.display());
    Ok(())
}

pub fn add(args: &AddArgs) -> Result<()> {
    let path = resolve_path(&args.ledger);
    let ledger = load(&path)?;
    let id = next_id(&ledger)?;
    let mut value = read_yaml(&path)?;
    let mapping = root_map(&mut value)?;
    let queue = sequence(mapping, "queue")?;
    let mut item = Mapping::new();
    item.insert(Value::from("id"), Value::from(id.clone()));
    item.insert(Value::from("title"), Value::from(args.title.clone()));
    item.insert(Value::from("scope"), Value::from(args.scope.clone()));
    item.insert(Value::from("outcome"), Value::from(args.outcome.clone()));
    item.insert(Value::from("blocked_by"), Value::Sequence(Vec::new()));
    item.insert(
        Value::from("acceptance"),
        Value::from(args.acceptance.clone()),
    );
    if let Some(patch) = &args.patch {
        item.insert(Value::from("patch"), Value::from(patch.clone()));
    }
    queue.push(Value::Mapping(item));
    write_yaml(&path, &value)?;
    println!("{id}");
    Ok(())
}

pub fn start(args: &crate::cli::IdArgs) -> Result<()> {
    let path = resolve_path(&args.ledger);
    let ledger = load(&path)?;
    let index = ledger
        .queue
        .iter()
        .position(|task| task.id == args.id)
        .with_context(|| format!("{} is not queued", args.id))?;
    ensure!(
        ledger.queue[index].blocked_by.is_empty(),
        "{} is blocked",
        args.id
    );
    let mut value = read_yaml(&path)?;
    let mapping = root_map(&mut value)?;
    let queue = sequence(mapping, "queue")?;
    let item = queue.remove(index);
    queue.insert(0, item);
    mapping.insert(Value::from("active"), Value::from(args.id.clone()));
    write_yaml(&path, &value)?;
    println!("active  {}", args.id);
    Ok(())
}

pub fn archive(args: &ArchiveArgs) -> Result<()> {
    let path = resolve_path(&args.ledger);
    let today = OffsetDateTime::now_utc()
        .date()
        .format(format_description!("[year]-[month]-[day]"))
        .context("format date")?;
    let mut value = read_yaml(&path)?;
    let mapping = root_map(&mut value)?;
    let queue = sequence(mapping, "queue")?;
    let position = queue
        .iter()
        .position(|item| item.get("id").and_then(Value::as_str) == Some(args.id.as_str()))
        .with_context(|| format!("{} is not queued", args.id))?;
    let mut item = queue.remove(position);
    if let Some(map) = item.as_mapping_mut() {
        map.remove(Value::from("blocked_by"));
        map.remove(Value::from("acceptance"));
        map.insert(Value::from("completed"), Value::from(today));
        map.insert(Value::from("evidence"), Value::from(args.evidence.clone()));
        map.insert(
            Value::from("disposition"),
            Value::from(args.disposition.as_str()),
        );
    }
    let archive = sequence(mapping, "archive")?;
    archive.insert(0, item);
    let next = mapping
        .get("queue")
        .and_then(Value::as_sequence)
        .and_then(|queue| queue.first())
        .and_then(|item| item.get("id"))
        .cloned();
    mapping.insert(Value::from("active"), next.unwrap_or(Value::Null));
    write_yaml(&path, &value)?;
    println!("archived  {}", args.id);
    Ok(())
}

fn read_yaml(path: &Path) -> Result<Value> {
    let raw = fs::read_to_string(path).with_context(|| path.display().to_string())?;
    serde_yml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}

fn write_yaml(path: &Path, value: &Value) -> Result<()> {
    fs::write(path, serde_yml::to_string(value)?).with_context(|| path.display().to_string())
}

fn root_map(value: &mut Value) -> Result<&mut Mapping> {
    value
        .as_mapping_mut()
        .context("ledger root must be a mapping")
}

fn sequence<'a>(mapping: &'a mut Mapping, key: &str) -> Result<&'a mut Vec<Value>> {
    mapping
        .get_mut(Value::from(key))
        .and_then(Value::as_sequence_mut)
        .with_context(|| format!("{key} must be a sequence"))
}

fn valid_prefix(prefix: &str) -> bool {
    let mut chars = prefix.chars();
    matches!(chars.next(), Some('A'..='Z'))
        && prefix.len() >= 2
        && prefix.len() <= 8
        && chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
}