pitchfork-cli 2.29.0

Daemons with DX
Documentation
//! External files are associated with a project, never with their storage directory.
use crate::Result;
use crate::env;
use crate::pitchfork_toml::{NamespaceEntry, NamespaceEntryRaw, PitchforkToml, current_meta};
use indexmap::IndexMap;
use miette::IntoDiagnostic;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;

#[derive(Clone, Debug, Serialize)]
pub struct Entry {
    pub namespace: String,
    pub dir: PathBuf,
    pub config: Vec<PathBuf>,
    /// Hostname label the registration names for the project, if any.
    pub label: Option<String>,
    pub source: &'static str,
}

#[derive(Default, Deserialize)]
struct Registrations {
    #[serde(default)]
    namespaces: IndexMap<String, NamespaceEntryRaw>,
}

#[derive(Default)]
struct Cache {
    initialized: bool,
    meta: Option<(SystemTime, u64)>,
    entries: Vec<Entry>,
}
static CACHE: Lazy<Mutex<Cache>> = Lazy::new(|| Mutex::new(Cache::default()));

/// Canonicalize existing paths and normalize missing paths for unregistering.
pub fn normalize(path: &Path) -> PathBuf {
    if let Ok(path) = path.canonicalize() {
        return dunce::simplified(&path).to_path_buf();
    }
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        env::CWD.join(path)
    };
    if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
        && let Ok(parent) = parent.canonicalize()
    {
        return dunce::simplified(&parent).join(name);
    }
    let mut result = PathBuf::new();
    for part in absolute.components() {
        match part {
            Component::CurDir => {}
            Component::ParentDir => {
                result.pop();
            }
            part => result.push(part.as_os_str()),
        }
    }
    result
}

pub fn resolve_path(dir: &Path, path: &str) -> PathBuf {
    let path = env::expand_tilde(path);
    normalize(&if path.is_absolute() {
        path
    } else {
        dir.join(path)
    })
}

fn parse_entries(content: &str) -> Result<Vec<Entry>> {
    let raw: Registrations = toml::from_str(content).into_diagnostic()?;
    Ok(raw
        .namespaces
        .into_iter()
        .filter(|(_, e)| !e.config.is_empty())
        .map(|(namespace, entry)| {
            let dir = normalize(&env::expand_tilde(entry.dir));
            let config = entry.config.iter().map(|p| resolve_path(&dir, p)).collect();
            Entry {
                namespace,
                dir,
                config,
                label: entry.label,
                source: "registry",
            }
        })
        .collect())
}

pub fn entries() -> Vec<Entry> {
    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
    let meta = current_meta(&env::PITCHFORK_GLOBAL_CONFIG_USER);
    if !cache.initialized || cache.meta != meta {
        cache.entries = if meta.is_some() {
            std::fs::read_to_string(&*env::PITCHFORK_GLOBAL_CONFIG_USER)
                .into_diagnostic()
                .and_then(|s| parse_entries(&s))
                .unwrap_or_else(|e| {
                    warn!("cannot read external configuration registry: {e}");
                    Vec::new()
                })
        } else {
            Vec::new()
        };
        cache.meta = meta;
        cache.initialized = true;
    }
    let mut entries = cache.entries.clone();
    drop(cache);
    if let Some(value) = std::env::var_os("PITCHFORK_CONFIG") {
        let cwd = normalize(&env::CWD);
        let dir = xx::file::find_up_all(
            &cwd,
            &[
                "pitchfork.local.toml",
                "pitchfork.toml",
                ".config/pitchfork.local.toml",
                ".config/pitchfork.toml",
            ],
        )
        .into_iter()
        .next()
        .and_then(|path| {
            let parent = path.parent()?;
            Some(
                if parent.file_name().is_some_and(|name| name == ".config") {
                    parent.parent()?.to_path_buf()
                } else {
                    parent.to_path_buf()
                },
            )
        })
        .unwrap_or_else(|| cwd.clone());
        let config = std::env::split_paths(&value)
            .filter(|p| !p.as_os_str().is_empty())
            .map(|p| resolve_path(&cwd, &p.to_string_lossy()))
            .collect();
        // Avoid calling namespace_for_project_dir here: namespace discovery uses this registry.
        entries.push(Entry {
            namespace: String::new(),
            dir,
            config,
            label: None,
            source: "env",
        });
    }
    entries
}

pub fn invalidate() {
    *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Cache::default();
}

pub fn paths_for(cwd: &Path) -> Vec<PathBuf> {
    let cwd = normalize(cwd);
    let mut entries: Vec<_> = entries()
        .into_iter()
        .filter(|e| cwd.starts_with(&e.dir))
        .collect();
    entries.sort_by_key(|e| (e.source == "env", e.dir.components().count()));
    let mut paths = Vec::new();
    for entry in entries {
        for path in entry.config {
            // Last attachment wins, even if it is also in PITCHFORK_CONFIG.
            paths.retain(|p| p != &path);
            paths.push(path);
        }
    }
    paths
}

/// Configuration files registered for this exact project directory.
///
/// Unlike [`paths_for`], this does not include files registered for an
/// ancestor: they belong to that project, not to this one.
pub fn configs_for_dir(dir: &Path) -> Vec<PathBuf> {
    let dir = normalize(dir);
    entries()
        .into_iter()
        .filter(|e| e.dir == dir)
        .flat_map(|e| e.config)
        .collect()
}

pub fn project_dir(path: &Path) -> Option<PathBuf> {
    let path = normalize(path);
    entries()
        .into_iter()
        .rev()
        .find(|e| e.config.contains(&path))
        .map(|e| e.dir)
}

pub fn namespace_for_dir(dir: &Path) -> Option<String> {
    let dir = normalize(dir);
    entries()
        .into_iter()
        .find(|e| e.source == "registry" && e.dir == dir)
        .map(|e| e.namespace)
}

/// The hostname label a registration names for the project at `dir`.
///
/// A pure lookup in the cached registry. It is what lets a tool register a
/// project under a generated namespace (needed to keep daemon IDs unique) and
/// still get the hostname it advertises.
pub fn label_for_dir(dir: &Path) -> Option<String> {
    let dir = normalize(dir);
    entries()
        .into_iter()
        .find(|e| e.source == "registry" && e.dir == dir)
        .and_then(|e| e.label)
}

/// Mutate under the same lock as the existing namespace and slug writers.
///
/// A `label` replaces the one already recorded for the namespace; `None` leaves
/// it alone. Returns whether anything was written.
pub fn add(namespace: &str, dir: &Path, file: &Path, label: Option<&str>) -> Result<bool> {
    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).into_diagnostic()?;
    }
    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
    let mut pt = if path.exists() {
        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
    } else {
        PitchforkToml::new(path.clone())
    };
    let dir = normalize(dir);
    let file = normalize(file);
    for (name, entry) in &pt.namespaces {
        if (name == namespace && normalize(&entry.dir) != dir)
            || (name != namespace
                && (entry.config.contains(&file)
                    || (!entry.config.is_empty() && normalize(&entry.dir) == dir)))
        {
            miette::bail!(
                "external configuration conflicts with namespace '{name}' ({})",
                entry.dir.display()
            );
        }
    }
    let entry = pt
        .namespaces
        .entry(namespace.to_string())
        .or_insert_with(|| crate::pitchfork_toml::NamespaceEntry {
            dir,
            config: Vec::new(),
            label: None,
        });
    let mut changed = false;
    if let Some(label) = label
        && entry.label.as_deref() != Some(label)
    {
        entry.label = Some(label.to_string());
        changed = true;
    }
    if !entry.config.contains(&file) {
        entry.config.push(file);
        changed = true;
    }
    if changed {
        pt.write_unlocked()?;
    }
    Ok(changed)
}

/// Detach `file` from a registration, reporting whether it was attached.
///
/// A registration with no attachment left is not a registration: `entries()`
/// drops it, so it neither names the project's namespace nor its label. The
/// label goes with the last attachment so that it cannot silently come back
/// when the directory is registered again without `--label`.
fn detach_file(entry: &mut NamespaceEntry, file: &Path) -> bool {
    let before = entry.config.len();
    entry.config.retain(|p| p != file);
    let detached = before != entry.config.len();
    if detached && entry.config.is_empty() {
        entry.label = None;
    }
    detached
}

pub fn remove(file: &Path) -> Result<Option<String>> {
    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
    if !path.exists() {
        return Ok(None);
    }
    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
    let mut pt = if path.exists() {
        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
    } else {
        PitchforkToml::new(path.clone())
    };
    let file = normalize(file);
    let mut removed = None;
    for (name, entry) in &mut pt.namespaces {
        if detach_file(entry, &file) {
            removed = Some(name.clone());
        }
    }
    if removed.is_some() {
        pt.write_unlocked()?;
    }
    Ok(removed)
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn registry_paths_are_relative_to_project_and_old_entries_are_compatible() {
        let tmp = tempfile::tempdir().unwrap();
        let root = normalize(tmp.path());
        let project = root.join("project");
        let external = root.join("state/app.toml");
        let project_str = project.to_string_lossy().into_owned();
        let external_str = external.to_string_lossy().into_owned();
        let doc = toml::toml! {
            [namespaces.old]
            dir = "/old"
            [namespaces.app]
            dir = (project_str)
            config = ["generated.toml", (external_str)]
        };
        let entries = parse_entries(&toml::to_string(&doc).unwrap()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(
            entries[0].config,
            vec![
                normalize(&project.join("generated.toml")),
                normalize(&external)
            ]
        );
        let raw = NamespaceEntryRaw {
            dir: "/old".into(),
            config: vec![],
            label: None,
        };
        let text = toml::to_string(&raw).unwrap();
        assert!(!text.contains("config"));
        assert!(!text.contains("label"));
        assert_eq!(entries[0].label, None);
    }

    /// The label lives exactly as long as the registration: it survives while
    /// any attachment remains and goes with the last one.
    #[test]
    fn detach_file_drops_label_with_last_attachment() {
        let a = PathBuf::from("/gen/a.toml");
        let b = PathBuf::from("/gen/b.toml");
        let mut entry = NamespaceEntry {
            dir: PathBuf::from("/shop"),
            config: vec![a.clone(), b.clone()],
            label: Some("shop".into()),
        };
        assert!(!detach_file(&mut entry, Path::new("/gen/none.toml")));
        assert_eq!(entry.label.as_deref(), Some("shop"));
        assert!(detach_file(&mut entry, &a));
        assert_eq!(entry.config, vec![b.clone()]);
        assert_eq!(entry.label.as_deref(), Some("shop"));
        assert!(detach_file(&mut entry, &b));
        assert!(entry.config.is_empty());
        assert_eq!(entry.label, None);
        // Removing again is a no-op and cannot resurrect anything.
        assert!(!detach_file(&mut entry, &b));
    }

    #[test]
    fn registry_label_parses_and_round_trips() {
        let doc = "[namespaces.shop-528f92b13a6784f0]\ndir = \"/shop\"\n\
                   config = [\"/gen/pitchfork.toml\"]\nlabel = \"shop\"\n";
        let entries = parse_entries(doc).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].namespace, "shop-528f92b13a6784f0");
        assert_eq!(entries[0].label.as_deref(), Some("shop"));
        let raw: Registrations = toml::from_str(doc).unwrap();
        let again = toml::to_string(&raw.namespaces["shop-528f92b13a6784f0"]).unwrap();
        assert!(again.contains("label = \"shop\""), "{again}");
    }
}