yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Project (balls-clone) enumeration and nested-delivery detection
//! (DESIGN §5.1 #1, §15 Y14).
//!
//! A **project** is one balls invocation path — the `bl` control plane keys its
//! per-project state at `$XDG_STATE_HOME/balls/clones/<percent-encoded-path>/`
//! (balls arch §1). The project's *identity* is the decoded path, and it is the
//! cwd every `bl … --json` runs in ([`balls`], DESIGN §5.1 #2). Enumeration is
//! therefore `readdir + percent-decode basename` — one query, nothing stored.
//!
//! **Nested-delivery detection** (§5.1 #1): a decoded path that itself lies
//! under the balls `plugins/bl-delivery/` tree is a ball's own work-worktree
//! that became a balls project — an *internal* clone. The UI hides internal
//! clones by default behind a toggle whose durable state is a `ui.json`
//! collapsed-style override (§4.1 `collapsed`: "explicit user expansion
//! overrides only"); the pure [`visible`] filter takes that resolved bool so
//! this module stays free of `ui_state` (the [`crate::nav`] discipline).

pub mod balls;
pub mod join;
pub mod runner;

use crate::xdg::percent_decode;
use std::path::{Path, PathBuf};

/// One enumerated balls project (§5.1 #1). `path` is the decoded invocation
/// path — the identity and the `bl` cwd; `internal` flags a nested-delivery
/// clone (hidden by default, [`visible`]). Both are derived from the clone
/// basename alone — nothing about the project is stored by yog.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
    pub path: PathBuf,
    pub internal: bool,
}

/// The bl-delivery territory a nested-delivery clone lives under, derived from
/// the clones dir (its parent is the balls state root; balls arch §1). `None`
/// only when `clones_dir` has no parent — then no path can be internal.
fn delivery_root(clones_dir: &Path) -> Option<PathBuf> {
    clones_dir
        .parent()
        .map(|state| state.join("plugins").join("bl-delivery"))
}

/// True iff `project` (a decoded invocation path) lies under the bl-delivery
/// tree — a ball's own worktree that became a balls project (§5.1 #1).
fn is_internal(project: &Path, delivery_root: Option<&Path>) -> bool {
    delivery_root.is_some_and(|d| project.starts_with(d))
}

/// Enumerate every balls project under `clones_dir` (§5.1 #1): each child dir's
/// basename percent-decodes to the project path, flagged `internal` when it
/// falls under bl-delivery. Sorted by path for a stable roster. A missing or
/// unreadable clones dir yields an empty Vec — the general path with no inputs,
/// not a bootstrap special case (the [`crate::binding`] discipline).
pub fn enumerate(clones_dir: &Path) -> Vec<Project> {
    let delivery = delivery_root(clones_dir);
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(clones_dir) else {
        return out;
    };
    for entry in entries.flatten() {
        if !entry.path().is_dir() {
            continue;
        }
        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
            continue;
        };
        let path = PathBuf::from(percent_decode(&name));
        let internal = is_internal(&path, delivery.as_deref());
        out.push(Project { path, internal });
    }
    out.sort_by(|a, b| a.path.cmp(&b.path));
    out
}

/// The projects shown under the "internal" toggle (§5.1 #1): every project when
/// `show_internal`, else only the non-internal clones. Pure over the resolved
/// toggle bool — the caller derives it from the `ui.json` collapsed override.
pub fn visible(projects: &[Project], show_internal: bool) -> Vec<&Project> {
    projects
        .iter()
        .filter(|p| show_internal || !p.internal)
        .collect()
}

#[cfg(test)]
mod tests;