wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! `wsb note`, `wsb link` and `wsb migrate`.
//!
//! A note or a link is written into the `.wsb/` of the worktree the command
//! runs in — the checkout of a project's main repo — and wsb never commits
//! it: it waits for the next commit, as any change does. Reading looks at
//! every worktree of every workspace in the workshop.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};

use crate::annotations::{HeldLink, HeldNote, Index, Link, Note, Ref};

/// Where a command that writes a note or a link writes it: the worktree it
/// runs in, which wsb must manage.
pub fn writing_root(cwd: &Path) -> Result<PathBuf> {
    crate::roots::find_worktree_root(cwd).with_context(|| {
        format!(
            "{} is in no worktree wsb manages, so there is no project to write into; run this inside one",
            cwd.display()
        )
    })
}

/// What a note or link is about, as given on the command line: refs, and the
/// conveniences that name a file, the worktree or the project from where the
/// command runs.
#[derive(Debug, Default, Clone)]
pub struct Targets {
    pub refs: Vec<String>,
    pub files: Vec<PathBuf>,
    pub worktree: bool,
    pub project: bool,
}

impl Targets {
    /// Every target as a ref, resolved against the worktree at `root`.
    pub fn resolve(&self, root: &Path) -> Result<Vec<Ref>> {
        let mut refs = Vec::new();
        for text in &self.refs {
            refs.push(text.parse::<Ref>()?);
        }
        for file in &self.files {
            refs.push(Ref::path_in(root, file)?);
        }
        if self.worktree {
            refs.push(Ref::worktree_of(root)?);
        }
        if self.project {
            refs.push(Ref::project_of(root)?);
        }
        Ok(refs)
    }

    pub fn is_empty(&self) -> bool {
        self.refs.is_empty() && self.files.is_empty() && !self.worktree && !self.project
    }
}

/// Write a note into the worktree at `cwd`, linked to every target, as
/// written on `machine`.
pub fn note_add(cwd: &Path, title: &str, body: &str, about: &Targets, machine: &str) -> Result<(Note, Vec<PathBuf>)> {
    let root = writing_root(cwd)?;
    let targets = about.resolve(&root)?;
    let note = Note::new(title, body, machine)?;
    let mut written = vec![note.write(&root)?];
    for target in targets {
        written.push(Link::new(Ref::Note { id: note.id.clone() }, target, None)?.write(&root)?);
    }
    Ok((note, written))
}

/// Rewrite a note held in the worktree at `cwd`: its title, its body, or both.
pub fn note_edit(cwd: &Path, id: &str, title: Option<&str>, body: Option<&str>) -> Result<(Note, PathBuf)> {
    if title.is_none() && body.is_none() {
        bail!("say what to change: --title, --body or --body-file");
    }
    let root = writing_root(cwd)?;
    let mut note = Note::read(&root, id)?
        .with_context(|| format!("this worktree ({}) holds no note {id}", root.display()))?;
    if let Some(title) = title {
        note.title = title.to_string();
    }
    if let Some(body) = body {
        note.body = body.to_string();
    }
    let path = note.write(&root)?;
    Ok((note, path))
}

/// Remove a note from the worktree at `cwd`, and every link of this checkout
/// that names it.
pub fn note_rm(cwd: &Path, id: &str) -> Result<Vec<PathBuf>> {
    let root = writing_root(cwd)?;
    let mut removed = vec![crate::annotations::remove_note(&root, id)?];
    let note = Ref::Note { id: id.to_string() };
    for link in Link::all(&root)? {
        if link.other_end(&note).is_some() {
            removed.push(crate::annotations::remove_link(&root, &link)?);
        }
    }
    Ok(removed)
}

/// Link two things, in the worktree at `cwd`.
pub fn link_add(cwd: &Path, from: &Ref, to: &Ref, label: Option<&str>) -> Result<PathBuf> {
    let root = writing_root(cwd)?;
    Link::new(from.clone(), to.clone(), label.map(str::to_string))?.write(&root)
}

/// Remove a link from the worktree at `cwd`.
pub fn link_rm(cwd: &Path, from: &Ref, to: &Ref, label: Option<&str>) -> Result<PathBuf> {
    let root = writing_root(cwd)?;
    crate::annotations::remove_link(&root, &Link::new(from.clone(), to.clone(), label.map(str::to_string))?)
}

/// Every note and link in the workshop.
pub fn index() -> Result<Index> {
    let workshop = wsb_prj::Workshop::from_env()?;
    Index::gather(&workshop)
}

/// The notes linked to `about`, or every note.
pub fn notes_about<'a>(index: &'a Index, about: Option<&Ref>) -> Vec<&'a HeldNote> {
    match about {
        None => index.notes.iter().collect(),
        Some(about) => index
            .notes
            .iter()
            .filter(|held| {
                let note = Ref::Note { id: held.note.id.clone() };
                index.links.iter().any(|link| link.link.other_end(&note) == Some(about))
            })
            .collect(),
    }
}

/// The links with `at` at one end, or every link.
pub fn links_at<'a>(index: &'a Index, at: Option<&'a Ref>) -> Vec<&'a HeldLink> {
    match at {
        None => index.links.iter().collect(),
        Some(at) => index.links_at(at).collect(),
    }
}

/// `wsb migrate`: the machine and every workspace of the workshop, and the
/// checkout `cwd` is in when it lies outside the workshop. On a machine with
/// no workshop, the machine and that checkout.
pub fn migrate(cwd: &Path) -> Result<crate::migrate::Report> {
    let workshop = match wsb_prj::Workshop::from_env() {
        Ok(workshop) => Some(workshop),
        Err(wsb_prj::WorkshopError::NotSet) => None,
        Err(other) => return Err(other.into()),
    };
    let mut report = match &workshop {
        Some(workshop) => crate::migrate::migrate_workshop(workshop)?,
        None => crate::migrate::migrate_machine(None)?,
    };
    if let Some(root) = crate::roots::find_super_root(cwd) {
        if workshop.as_ref().is_none_or(|workshop| workshop.locate(&root).is_err()) {
            let outside = crate::migrate::migrate_outside(&root)?;
            report.done.extend(outside.done);
            report.refused.extend(outside.refused);
        }
    }
    Ok(report)
}

#[cfg(test)]
#[path = "annotations_tests.rs"]
mod tests;