Skip to main content

semora_cli/commands/
map.rs

1//! `semora project map ls | add | rm`: the rules that arrange a project's tree.
2
3use anyhow::bail;
4use semora_proto::{Mapping, MappingList};
5
6use crate::args::{MapArgs, MapCmd};
7use crate::client::Client;
8use crate::{Ctx, exit, output, project_link};
9
10pub async fn run(ctx: &Ctx, args: MapArgs) -> anyhow::Result<i32> {
11    let client = ctx.client()?;
12    let slug = project_slug(&client, &ctx.settings.config_dir, args.project.as_deref()).await?;
13    match args.cmd {
14        MapCmd::Ls => {
15            let list = client.mappings(&slug).await?.mappings;
16            show(ctx, &slug, &list);
17        }
18        MapCmd::Add {
19            pattern,
20            folder,
21            at,
22        } => {
23            // Nothing is sent for a shell-mangled argument.
24            reject_drive_path("PATTERN", &pattern)?;
25            reject_drive_path("FOLDER", &folder)?;
26            let mut list = client.mappings(&slug).await?.mappings;
27            let at = match at {
28                None => list.len() + 1,
29                Some(n) if (1..=list.len() + 1).contains(&n) => n,
30                Some(_) => bail!("{}; --at can be 1 to {}", count(list.len()), list.len() + 1),
31            };
32            list.insert(at - 1, Mapping { pattern, folder });
33            let stored = client.set_mappings(&slug, &list).await?.mappings;
34            if !ctx.json {
35                let done = output::out().done;
36                anstream::println!(
37                    "{done}Added{done:#} rule {at}: {} → {}",
38                    stored[at - 1].pattern,
39                    stored[at - 1].folder
40                );
41            }
42            show(ctx, &slug, &stored);
43        }
44        MapCmd::Rm { n } => {
45            let mut list = client.mappings(&slug).await?.mappings;
46            if n == 0 || n > list.len() {
47                bail!(
48                    "there is no rule {n}; there {} {}",
49                    if list.len() == 1 { "is" } else { "are" },
50                    list.len()
51                );
52            }
53            let gone = list.remove(n - 1);
54            let stored = client.set_mappings(&slug, &list).await?.mappings;
55            if !ctx.json {
56                let done = output::out().done;
57                anstream::println!(
58                    "{done}Removed{done:#} rule {n}: {} → {}",
59                    gone.pattern,
60                    gone.folder
61                );
62            }
63            show(ctx, &slug, &stored);
64        }
65    }
66    Ok(exit::OK)
67}
68
69/// "there are 2 rules", "there is 1 rule", "there are 0 rules".
70fn count(n: usize) -> String {
71    if n == 1 {
72        "there is 1 rule".to_string()
73    } else {
74        format!("there are {n} rules")
75    }
76}
77
78/// Git Bash on Windows turns an argument that starts with `/` into a drive path. Say so
79/// instead of sending it.
80fn reject_drive_path(what: &str, value: &str) -> anyhow::Result<()> {
81    let b = value.as_bytes();
82    if b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'/' {
83        bail!(
84            "{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."
85        );
86    }
87    Ok(())
88}
89
90/// `--project`, else the `.semora` file found from the current folder upward, with Core's
91/// check that the file's server is the one in use, under the id the server has for it now.
92pub async fn project_slug(
93    client: &Client,
94    config_dir: &std::path::Path,
95    flag: Option<&str>,
96) -> anyhow::Result<String> {
97    if let Some(slug) = flag {
98        return Ok(slug.to_string());
99    }
100    let here = std::env::current_dir()?;
101    // The lookup walks up from the folder it is given, and `resolve` fails when the file is for
102    // another server than the one in use. Nothing is sent for the project before it.
103    match project_link::find(&here)? {
104        Some(file) => {
105            let link = project_link::resolve(client, config_dir, file).await?;
106            Ok(project_link::follow(client, link).await?.slug)
107        }
108        None => {
109            bail!("no project: pass --project ID or run this inside a folder with a .semora file")
110        }
111    }
112}
113
114fn show(ctx: &Ctx, slug: &str, list: &[Mapping]) {
115    if ctx.json {
116        output::json(&MappingList {
117            mappings: list.to_vec(),
118        });
119    } else {
120        print_list(slug, list);
121    }
122}
123
124pub fn print_list(slug: &str, list: &[Mapping]) {
125    if list.is_empty() {
126        println!("No mappings for {slug}. Every document shows at its real path.");
127        return;
128    }
129    println!(
130        "Mappings for {slug} (the first match wins; a path no rule matches stays where it is)"
131    );
132    let number_width = list.len().to_string().len();
133    let pattern_width = list
134        .iter()
135        .map(|m| m.pattern.chars().count())
136        .max()
137        .unwrap_or(0);
138    for (i, m) in list.iter().enumerate() {
139        println!(
140            "  {:>number_width$}  {:<pattern_width$}  →  {}",
141            i + 1,
142            m.pattern,
143            m.folder
144        );
145    }
146}