qqq-cli 0.12.0

Local-first task queue for coding agent sessions
use crate::{
    errors::{Code, Info},
    selection::{self, Prepared, Selectors, Visibility},
};
use anyhow::{Context, Result, ensure};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::{
    collections::HashSet,
    fs::{File, OpenOptions},
    io::{Read, Write},
    path::{Path, PathBuf},
};

const MAX_BYTES: u64 = 1_048_576;

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SavedView {
    pub name: String,
    #[serde(default)]
    pub criteria: Selectors,
    #[serde(default)]
    pub include_archived: bool,
    pub max_completed: Option<i64>,
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Catalog {
    pub version: u32,
    pub views: Vec<SavedView>,
}

impl Default for Catalog {
    fn default() -> Self {
        Self {
            version: 1,
            views: Vec::new(),
        }
    }
}

fn valid_name(name: &str) -> Result<()> {
    ensure!(
        !name.is_empty() && name.trim() == name && !name.chars().any(char::is_control),
        Info::invalid_argument(
            "NAME",
            "View names must be nonempty without controls or surrounding whitespace"
        )
    );
    Ok(())
}

pub(crate) fn validate(view: &mut SavedView) -> Result<()> {
    valid_name(&view.name)?;
    ensure!(
        view.max_completed.is_none_or(|limit| limit >= 0),
        Info::invalid_argument("--max-completed", "Completed limit must be nonnegative")
    );
    selection::prepare(None, &view.criteria, Visibility::default())?;
    view.criteria.tags = crate::tags::normalize(&view.criteria.tags)?;
    Ok(())
}

pub fn path() -> Result<PathBuf> {
    let db = crate::db::database_path(false)?;
    let project = db
        .parent()
        .and_then(Path::parent)
        .context("Missing project root")?;
    Ok(project.join(".qqq-views.json"))
}

pub fn load(path: &Path) -> Result<Catalog> {
    let loaded = (|| -> Result<Catalog> {
        let file = match File::open(path) {
            Ok(file) => file,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Catalog::default());
            }
            Err(error) => return Err(error.into()),
        };
        let mut source = String::new();
        file.take(MAX_BYTES + 1).read_to_string(&mut source)?;
        ensure!(
            source.len() as u64 <= MAX_BYTES,
            "View catalog exceeds 1 MiB"
        );
        let mut catalog: Catalog = serde_json::from_str(&source)?;
        ensure!(
            catalog.version == 1,
            "Unsupported view version {}; expected 1",
            catalog.version
        );
        let mut names = HashSet::new();
        for view in &mut catalog.views {
            validate(view)?;
            ensure!(
                names.insert(view.name.clone()),
                "Duplicate view name {}",
                view.name
            );
        }
        catalog.views.sort_by(|a, b| a.name.cmp(&b.name));
        Ok(catalog)
    })();
    loaded.context(
        Info::new(Code::ConfigError, "Invalid project views file")
            .detail("path", path.display().to_string())
            .detail("reason", "invalid_views"),
    )
}

fn unknown(name: &str) -> Info {
    Info::invalid_argument("--view", format!("Unknown view {name:?}"))
        .detail("view", name)
        .detail("reason", "unknown_view")
}

pub fn find(catalog: &Catalog, name: &str) -> Result<SavedView> {
    catalog
        .views
        .iter()
        .find(|view| view.name == name)
        .cloned()
        .context(unknown(name))
}

fn update(
    path: &Path,
    change: impl FnOnce(&mut Catalog) -> Result<SavedView>,
) -> Result<SavedView> {
    let project = path.parent().context("Missing views directory")?;
    let lock = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(project.join(".qqq/views.lock"))?;
    lock.lock_exclusive()?;
    let mut catalog = load(path)?;
    let changed = change(&mut catalog)?;
    catalog.views.sort_by(|a, b| a.name.cmp(&b.name));
    let bytes = serde_json::to_vec_pretty(&catalog)?;
    ensure!(
        (bytes.len() as u64) < MAX_BYTES,
        Info::invalid_argument("view", "View catalog exceeds 1 MiB")
    );
    let mut temporary = tempfile::NamedTempFile::new_in(project)?;
    if let Ok(metadata) = std::fs::metadata(path) {
        temporary
            .as_file()
            .set_permissions(metadata.permissions())?;
    }
    temporary.write_all(&bytes)?;
    temporary.write_all(b"\n")?;
    temporary.as_file().sync_all()?;
    temporary
        .persist(path)
        .map_err(|error| anyhow::Error::new(error.error))?;
    #[cfg(unix)]
    File::open(project)?.sync_all()?;
    Ok(changed)
}

pub fn save(path: &Path, mut view: SavedView) -> Result<SavedView> {
    validate(&mut view)?;
    update(path, |catalog| {
        if let Some(existing) = catalog
            .views
            .iter_mut()
            .find(|existing| existing.name == view.name)
        {
            *existing = view.clone();
        } else {
            catalog.views.push(view.clone());
        }
        Ok(view)
    })
}

pub fn create(path: &Path, mut view: SavedView) -> Result<SavedView> {
    validate(&mut view)?;
    update(path, |catalog| {
        ensure!(
            !catalog
                .views
                .iter()
                .any(|existing| existing.name == view.name),
            Info::invalid_argument(
                "NAME",
                format!("View {:?} already exists; choose another name", view.name)
            )
            .detail("view", view.name.as_str())
            .detail("reason", "duplicate_view")
        );
        catalog.views.push(view.clone());
        Ok(view)
    })
}

pub fn remove(path: &Path, name: &str) -> Result<SavedView> {
    valid_name(name)?;
    update(path, |catalog| {
        let index = catalog
            .views
            .iter()
            .position(|view| view.name == name)
            .context(unknown(name))?;
        Ok(catalog.views.remove(index))
    })
}

#[derive(Clone)]
pub struct ViewContext {
    pub path: PathBuf,
    pub selectors: Selectors,
    pub visibility: Visibility,
    pub active: Option<SavedView>,
    pub prepared: Prepared,
}

impl ViewContext {
    pub fn new(
        path: PathBuf,
        selectors: Selectors,
        visibility: Visibility,
        active: Option<SavedView>,
    ) -> Result<Self> {
        let prepared = prepare(active.as_ref(), &selectors, visibility)?;
        Ok(Self {
            path,
            selectors,
            visibility,
            active,
            prepared,
        })
    }
    pub fn select(&mut self, active: Option<SavedView>) -> Result<()> {
        let prepared = prepare(active.as_ref(), &self.selectors, self.visibility)?;
        self.active = active;
        self.prepared = prepared;
        Ok(())
    }
}

pub fn prepare(
    view: Option<&SavedView>,
    explicit: &Selectors,
    mut visibility: Visibility,
) -> Result<Prepared> {
    if let Some(view) = view {
        visibility.include_archived = visibility.include_archived.or(Some(view.include_archived));
        visibility.max_completed = visibility.max_completed.or(Some(view.max_completed));
    }
    selection::prepare(view.map(|view| &view.criteria), explicit, visibility)
}

pub fn describe(view: &SavedView) -> Vec<String> {
    let criteria = &view.criteria;
    vec![
        format!("View: {}", view.name),
        format!(
            "Tags (all): {}",
            if criteria.tags.is_empty() {
                "Any".to_owned()
            } else {
                criteria.tags.join(", ")
            }
        ),
        format!(
            "Filter: {}",
            criteria
                .filter
                .as_deref()
                .map(crate::output::clean)
                .unwrap_or_else(|| "Any".to_owned())
        ),
        format!(
            "Query: {}",
            criteria
                .query
                .as_deref()
                .map(crate::output::clean)
                .unwrap_or_else(|| "Any".to_owned())
        ),
        format!(
            "Statuses (any): {}",
            if criteria.statuses.is_empty() {
                "Any".to_owned()
            } else {
                criteria
                    .statuses
                    .iter()
                    .map(|status| status.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            }
        ),
        format!(
            "Readiness: {}",
            match criteria.readiness {
                None => "Any",
                Some(selection::Readiness::Ready) => "Ready",
                Some(selection::Readiness::Blocked) => "Blocked",
            }
        ),
        format!(
            "Archived: {}",
            if view.include_archived {
                "Shown"
            } else {
                "Hidden"
            }
        ),
        format!(
            "Completed limit: {}",
            view.max_completed
                .map_or_else(|| "All".to_owned(), |limit| limit.to_string())
        ),
    ]
}

pub fn render(value: &serde_json::Value) -> String {
    if let Some(views) = value.as_array() {
        if views.is_empty() {
            return "No saved views.".to_owned();
        }
        return views
            .iter()
            .filter_map(|view| view["name"].as_str())
            .collect::<Vec<_>>()
            .join("\n");
    }
    let view: SavedView = serde_json::from_value(value.clone()).expect("view output is typed");
    describe(&view).join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Barrier};

    #[test]
    fn concurrent_creations_reject_same_name_without_replacing_winner() {
        let directory = tempfile::tempdir().unwrap();
        std::fs::create_dir(directory.path().join(".qqq")).unwrap();
        let path = directory.path().join(".qqq-views.json");
        let barrier = Arc::new(Barrier::new(2));
        let threads = ["First", "Second"].map(|query| {
            let path = path.clone();
            let barrier = barrier.clone();
            std::thread::spawn(move || {
                barrier.wait();
                create(
                    &path,
                    SavedView {
                        name: "Shared".into(),
                        criteria: Selectors {
                            query: Some(query.into()),
                            ..Default::default()
                        },
                        include_archived: false,
                        max_completed: None,
                    },
                )
            })
        });
        let results = threads.map(|thread| thread.join().unwrap());
        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
        assert!(
            results
                .iter()
                .find_map(|result| result.as_ref().err())
                .unwrap()
                .to_string()
                .contains("already exists")
        );
        let winner = results.into_iter().find_map(Result::ok).unwrap();
        let catalog = load(&path).unwrap();
        assert_eq!(catalog.views.len(), 1);
        assert_eq!(catalog.views[0].criteria.query, winner.criteria.query);
        let bytes = std::fs::read(&path).unwrap();
        assert!(create(&path, winner).is_err());
        assert_eq!(std::fs::read(&path).unwrap(), bytes);
    }
}