wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Notes and links, committed in projects' main repos.
//!
//! A note ([`notes`]) stands alone; a link ([`links`]) says two things belong
//! together, and either end can be anything a [`Ref`] names — a board, a
//! card, a project, a worktree, a file, a machine, a note, a session, a
//! message, a URL. That is the whole association system: nothing else in wsb
//! attaches meaning to things.
//!
//! Both are files in the `.wsb/` of a project's main repo, so they travel
//! with every clone. They are written into the checkout of the worktree
//! they are made in, and wsb never commits them: they wait for the next
//! commit like any other change.
//!
//! Read back, every worktree of every workspace in the workshop is read
//! ([`Index::gather`]), and the same note or link held by several is one,
//! with every place that holds it.

pub mod links;
pub mod notes;
pub mod refs;

pub use links::Link;
pub use notes::Note;
pub use refs::{ProjectId, Ref};

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Serialize;

/// Write `text` to `path` whole: staged beside it and renamed over it.
pub(crate) fn write_file(path: &Path, text: &str) -> Result<()> {
    let parent = path.parent().with_context(|| format!("{} has no folder", path.display()))?;
    std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", parent.display()))?;
    let staged = parent.join(format!(
        ".{}.partial",
        path.file_name().map(|name| name.to_string_lossy()).unwrap_or_default()
    ));
    std::fs::write(&staged, text).with_context(|| format!("cannot write {}", staged.display()))?;
    std::fs::rename(&staged, path).with_context(|| format!("cannot write {}", path.display()))?;
    Ok(())
}

/// The files in `dir` with `extension`, by stem, in name order. A missing
/// folder holds none; anything else in it is refused by name, so a stray file
/// is seen rather than skipped.
pub(crate) fn files_with_extension(dir: &Path, extension: &str) -> Result<Vec<(String, PathBuf)>> {
    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(e).with_context(|| format!("cannot read {}", dir.display())),
    };
    let mut files = Vec::new();
    for entry in entries {
        let entry = entry.with_context(|| format!("cannot read {}", dir.display()))?;
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        match name.strip_suffix(&format!(".{extension}")) {
            Some(stem) if entry.file_type().map(|t| t.is_file()).unwrap_or(false) => {
                files.push((stem.to_string(), path))
            }
            _ => anyhow::bail!(
                "{} is not a .{extension} file, and nothing else belongs in {}",
                path.display(),
                dir.display()
            ),
        }
    }
    files.sort();
    Ok(files)
}

/// Remove note `id` from the checkout at `root`. Refused when it holds no
/// such note.
pub fn remove_note(root: &Path, id: &str) -> Result<PathBuf> {
    let path = notes::path_of(root, &notes::check_id(id)?);
    std::fs::remove_file(&path).with_context(|| format!("there is no note {id} in {}", root.display()))?;
    Ok(path)
}

/// Remove a link from the checkout at `root`. Refused when it holds no such
/// link.
pub fn remove_link(root: &Path, link: &Link) -> Result<PathBuf> {
    let path = links::path_of(root, &link.id());
    std::fs::remove_file(&path).with_context(|| {
        format!("there is no link {} → {} in {}", link.from, link.to, root.display())
    })?;
    Ok(path)
}

/// A worktree that holds a note or a link.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Place {
    /// The workspace's name: the project's folder in the workshop.
    pub workspace: String,
    pub worktree: String,
    pub root: PathBuf,
}

/// A link, and every worktree that holds it.
#[derive(Debug, Clone, Serialize)]
pub struct HeldLink {
    pub id: String,
    #[serde(flatten)]
    pub link: Link,
    pub places: Vec<Place>,
}

/// One version of a note, and every worktree that holds that version. A note
/// edited on one branch and not on another is two versions until the
/// branches meet.
#[derive(Debug, Clone, Serialize)]
pub struct HeldNote {
    #[serde(flatten)]
    pub note: Note,
    pub places: Vec<Place>,
}

/// Every note and link in the workshop.
#[derive(Debug, Clone, Default, Serialize)]
pub struct Index {
    pub notes: Vec<HeldNote>,
    pub links: Vec<HeldLink>,
    /// What could not be read, one line per worktree, said rather than
    /// skipped.
    pub problems: Vec<String>,
}

impl Index {
    /// Read every worktree of every workspace in `workshop`.
    pub fn gather(workshop: &wsb_prj::Workshop) -> Result<Self> {
        let mut places = Vec::new();
        for workspace in workshop.workspaces()? {
            for worktree in workspace.worktrees()? {
                places.push(Place {
                    workspace: workspace.name().to_string(),
                    worktree: worktree.name,
                    root: worktree.path,
                });
            }
        }
        Ok(Self::of(places))
    }

    /// Read the given worktrees.
    pub fn of(places: Vec<Place>) -> Self {
        let mut links: BTreeMap<String, (Link, Vec<Place>)> = BTreeMap::new();
        let mut notes: BTreeMap<(String, String), (Note, Vec<Place>)> = BTreeMap::new();
        let mut problems = Vec::new();
        for place in places {
            match Link::all(&place.root) {
                Ok(held) => {
                    for link in held {
                        links.entry(link.id()).or_insert_with(|| (link, Vec::new())).1.push(place.clone());
                    }
                }
                Err(e) => problems.push(format!("{}/{}: {e:#}", place.workspace, place.worktree)),
            }
            match Note::all(&place.root) {
                Ok(held) => {
                    for note in held {
                        notes
                            .entry((note.id.clone(), note.render()))
                            .or_insert_with(|| (note, Vec::new()))
                            .1
                            .push(place.clone());
                    }
                }
                Err(e) => problems.push(format!("{}/{}: {e:#}", place.workspace, place.worktree)),
            }
        }
        Self {
            links: links
                .into_iter()
                .map(|(id, (link, places))| HeldLink { id, link, places })
                .collect(),
            notes: notes.into_values().map(|(note, places)| HeldNote { note, places }).collect(),
            problems,
        }
    }

    /// Every link with `end` at one end.
    pub fn links_at<'a>(&'a self, end: &'a Ref) -> impl Iterator<Item = &'a HeldLink> + 'a {
        self.links.iter().filter(move |held| held.link.other_end(end).is_some())
    }

    /// Every version of note `id`.
    pub fn note(&self, id: &str) -> Vec<&HeldNote> {
        self.notes.iter().filter(|held| held.note.id == id).collect()
    }

    /// Every project a link names, by its main repo.
    pub fn named_projects(&self) -> BTreeSet<ProjectId> {
        self.links
            .iter()
            .flat_map(|held| [&held.link.from, &held.link.to])
            .filter_map(|end| end.project().cloned())
            .collect()
    }
}

#[cfg(test)]
mod tests;