semora-cli 1.0.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `semora project map ls | add | rm`: the rules that arrange a project's tree.

use anyhow::bail;
use semora_proto::{Mapping, MappingList};

use crate::args::{MapArgs, MapCmd};
use crate::client::Client;
use crate::{Ctx, exit, output, project_link};

pub async fn run(ctx: &Ctx, args: MapArgs) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let slug = project_slug(&client, args.project.as_deref())?;
    match args.cmd {
        MapCmd::Ls => {
            let list = client.mappings(&slug).await?.mappings;
            show(ctx, &slug, &list);
        }
        MapCmd::Add {
            pattern,
            folder,
            at,
        } => {
            // Nothing is sent for a shell-mangled argument.
            reject_drive_path("PATTERN", &pattern)?;
            reject_drive_path("FOLDER", &folder)?;
            let mut list = client.mappings(&slug).await?.mappings;
            let at = match at {
                None => list.len() + 1,
                Some(n) if (1..=list.len() + 1).contains(&n) => n,
                Some(_) => bail!("{}; --at can be 1 to {}", count(list.len()), list.len() + 1),
            };
            list.insert(at - 1, Mapping { pattern, folder });
            let stored = client.set_mappings(&slug, &list).await?.mappings;
            if !ctx.json {
                let done = output::out().done;
                anstream::println!(
                    "{done}Added{done:#} rule {at}: {} → {}",
                    stored[at - 1].pattern,
                    stored[at - 1].folder
                );
            }
            show(ctx, &slug, &stored);
        }
        MapCmd::Rm { n } => {
            let mut list = client.mappings(&slug).await?.mappings;
            if n == 0 || n > list.len() {
                bail!(
                    "there is no rule {n}; there {} {}",
                    if list.len() == 1 { "is" } else { "are" },
                    list.len()
                );
            }
            let gone = list.remove(n - 1);
            let stored = client.set_mappings(&slug, &list).await?.mappings;
            if !ctx.json {
                let done = output::out().done;
                anstream::println!(
                    "{done}Removed{done:#} rule {n}: {} → {}",
                    gone.pattern,
                    gone.folder
                );
            }
            show(ctx, &slug, &stored);
        }
    }
    Ok(exit::OK)
}

/// "there are 2 rules", "there is 1 rule", "there are 0 rules".
fn count(n: usize) -> String {
    if n == 1 {
        "there is 1 rule".to_string()
    } else {
        format!("there are {n} rules")
    }
}

/// Git Bash on Windows turns an argument that starts with `/` into a drive path. Say so
/// instead of sending it.
fn reject_drive_path(what: &str, value: &str) -> anyhow::Result<()> {
    let b = value.as_bytes();
    if b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'/' {
        bail!(
            "{what} looks like a drive path. Your shell may have rewritten a leading \"/\" (Git Bash on Windows does). Leave the slash off, or use \".\" for the project root."
        );
    }
    Ok(())
}

/// `--project`, else the `.semora` file found from the current folder upward, with Core's
/// check that the file's server is the one in use.
pub fn project_slug(client: &Client, flag: Option<&str>) -> anyhow::Result<String> {
    if let Some(slug) = flag {
        return Ok(slug.to_string());
    }
    let here = std::env::current_dir()?;
    // The lookup walks up from the folder it is given, and `check_server` fails, with both
    // servers named, when the file's server is not the one in use. Nothing is sent before it.
    match project_link::find(&here)? {
        Some(link) => {
            link.check_server(client)?;
            Ok(link.slug.clone())
        }
        None => {
            bail!("no project: pass --project ID or run this inside a folder with a .semora file")
        }
    }
}

fn show(ctx: &Ctx, slug: &str, list: &[Mapping]) {
    if ctx.json {
        output::json(&MappingList {
            mappings: list.to_vec(),
        });
    } else {
        print_list(slug, list);
    }
}

pub fn print_list(slug: &str, list: &[Mapping]) {
    if list.is_empty() {
        println!("No mappings for {slug}. Every document shows at its real path.");
        return;
    }
    println!(
        "Mappings for {slug} (the first match wins; a path no rule matches stays where it is)"
    );
    let number_width = list.len().to_string().len();
    let pattern_width = list
        .iter()
        .map(|m| m.pattern.chars().count())
        .max()
        .unwrap_or(0);
    for (i, m) in list.iter().enumerate() {
        println!(
            "  {:>number_width$}  {:<pattern_width$}  →  {}",
            i + 1,
            m.pattern,
            m.folder
        );
    }
}