qqq-cli 0.7.0

Local-first task queue for coding agent sessions
use super::ExternalDraft;
use crate::db::{ContentConflict, ContentSnapshot, Db, ParentChange, Task};
use crate::images::ImageInput;
use crate::tui::draft::Composition;
use anyhow::{Context, Result};
use serde_json::json;
use std::{
    fs,
    io::{self, IsTerminal, Write},
    path::Path,
};

pub struct ExternalEdit<'a> {
    pub description: Option<&'a str>,
    pub images: &'a [ImageInput],
    pub parent: Option<ParentChange>,
    pub priority: Option<i64>,
}

fn input(prompt: &str) -> Result<String> {
    eprint!("{prompt}");
    io::stderr().flush()?;
    let mut answer = String::new();
    io::stdin().read_line(&mut answer)?;
    Ok(answer.trim().to_ascii_lowercase())
}

fn confirm(prompt: &str) -> Result<bool> {
    Ok(matches!(input(prompt)?.as_str(), "y" | "yes"))
}

fn recovery_files(
    draft: &ExternalDraft,
    composition: &Composition,
    current: Option<&str>,
) -> Result<()> {
    if let Some(current) = current {
        fs::write(draft.directory().join("current.txt"), current)?;
    } else {
        let path = draft.directory().join("current.txt");
        if path.exists() {
            fs::remove_file(path)?;
        }
    }
    let mut attachments = Vec::new();
    for (index, image) in composition.images.iter().enumerate() {
        let directory = draft.directory().join(format!("image-{}", index + 1));
        fs::create_dir_all(&directory)?;
        let name = Path::new(&image.name)
            .file_name()
            .context("Invalid pending image name")?;
        let path = directory.join(name);
        fs::write(&path, &image.data)?;
        attachments.push(json!({"name": image.name, "path": path}));
    }
    fs::write(
        draft.directory().join("attachments.json"),
        serde_json::to_vec_pretty(&attachments)?,
    )?;
    Ok(())
}

fn recovery(draft: &ExternalDraft) -> crate::errors::Recovery {
    let current = draft.directory().join("current.txt");
    let attachments = draft.directory().join("attachments.json");
    crate::errors::Recovery {
        local_draft: draft.path.clone(),
        current_text: current.exists().then_some(current),
        attachments_manifest: attachments.exists().then_some(attachments),
    }
}

pub fn edit_external(
    db: &mut Db,
    snapshot: ContentSnapshot,
    request: ExternalEdit<'_>,
) -> Result<Task> {
    let id = snapshot.task.id;
    let mut expected_revision = snapshot.task.content_revision;
    let mut draft = ExternalDraft::new(request.description.unwrap_or(&snapshot.task.description))?;
    let mut composition = Composition {
        description: draft.edit()?,
        images: request.images.to_vec(),
        image_spans: Vec::new(),
    };
    loop {
        match db.edit_composition_guarded(
            id,
            &composition,
            request.parent,
            request.priority,
            Some(expected_revision),
        ) {
            Ok(task) => {
                draft.cleanup();
                return Ok(task);
            }
            Err(error) => {
                draft.keep();
                let conflict = error.downcast_ref::<ContentConflict>();
                let current = conflict.and_then(|conflict| conflict.current.as_ref());
                recovery_files(
                    &draft,
                    &composition,
                    current.map(|current| current.description.as_str()),
                )
                .context("Failed to preserve editor recovery files")
                .with_context(|| recovery(&draft))?;
                if !crate::errors::json_output() || io::stdin().is_terminal() {
                    eprintln!("{error:#}\nLocal draft: {}", draft.path.display());
                    if let Some(current) = current {
                        eprintln!(
                            "Current DB text (revision {}): {}",
                            current.revision,
                            draft.directory().join("current.txt").display()
                        );
                    }
                    if !composition.images.is_empty() {
                        eprintln!(
                            "Pending image bytes: {}",
                            draft.directory().join("attachments.json").display()
                        );
                    }
                }
                if conflict.is_none() || current.is_none() || !io::stdin().is_terminal() {
                    return Err(error.context(recovery(&draft)));
                }
                let current = current.expect("checked current content");
                loop {
                    match input(
                        "[r] Reload DB text  [o] Overwrite DB text  [k] Keep draft and exit: ",
                    )
                    .with_context(|| recovery(&draft))?
                    .as_str()
                    {
                        "r" => {
                            if !confirm(
                                "Discard local text and pending images, then reload? (y/N): ",
                            )
                            .with_context(|| recovery(&draft))?
                            {
                                continue;
                            }
                            let snapshot = match db.content_snapshot(id) {
                                Ok(snapshot) => snapshot,
                                Err(error) => {
                                    return Err(error.context(recovery(&draft)));
                                }
                            };
                            expected_revision = snapshot.task.content_revision;
                            fs::write(&draft.path, &snapshot.task.description)
                                .with_context(|| recovery(&draft))?;
                            composition.description =
                                draft.edit().with_context(|| recovery(&draft))?;
                            composition.images.clear();
                            break;
                        }
                        "o" => {
                            if !confirm(&format!(
                                "Replace DB text at revision {} with local draft and pending images? (y/N): ",
                                current.revision
                            )).with_context(|| recovery(&draft))? {
                                continue;
                            }
                            expected_revision = current.revision;
                            break;
                        }
                        "k" | "" => {
                            return Err(error.context(recovery(&draft)));
                        }
                        _ => eprintln!("Choose r, o, or k."),
                    }
                }
            }
        }
    }
}