Skip to main content

zad_cli/cli/
permissions.rs

1//! Shared CLI plumbing for the staged-commit permissions workflow.
2//!
3//! Each service's clap tree pulls in [`StagingAction`] as an enum
4//! variant and forwards matched subcommands to the generic dispatchers
5//! here. The per-service CLI stays tiny — it knows its service type
6//! ([`PermissionsService`] impl) and lets the shared code handle
7//! mutation parsing, staging, signing, and JSON formatting.
8
9use clap::{Args, Subcommand, ValueEnum};
10use serde::Serialize;
11
12use zad::error::{Result, ZadError};
13use zad::permissions::mutation::{ListKind, Mutation};
14use zad::permissions::service::{PermissionsService, global_path, local_path_current};
15use zad::permissions::signing;
16use zad::permissions::staging;
17
18// ---------------------------------------------------------------------------
19// clap types
20// ---------------------------------------------------------------------------
21
22/// Staging-workflow subcommands common to every permissions-bearing
23/// service. Each service embeds this under its `permissions` subgroup.
24#[derive(Debug, Subcommand)]
25pub enum StagingAction {
26    /// Print whether a pending policy exists at each scope.
27    Status(ScopeArgs),
28    /// Show the unified diff between live and pending (if any).
29    Diff(ScopeArgs),
30    /// Discard the pending policy without touching the live file.
31    Discard(ScopeArgs),
32    /// Promote the pending policy to live and upsert a trust-store
33    /// entry signed with the keychain-held signing key.
34    Commit(ScopeArgs),
35    /// Sign the live file with the keychain-held signing key and
36    /// upsert its entry in the per-machine trust store. Use this to
37    /// trust a hand-edited file or a permissions file shipped from
38    /// another machine. Requires `zad signing init` to have run.
39    Sign(ScopeArgs),
40
41    /// Queue an allow/deny pattern change. Writes to the pending file.
42    #[command(alias = "add-pattern")]
43    Add(PatternMutationArgs),
44    /// Queue an allow/deny pattern removal.
45    #[command(alias = "remove-pattern")]
46    Remove(PatternMutationArgs),
47
48    /// Queue a content `deny_words` / `deny_patterns` / `max_length`
49    /// change.
50    Content(ContentMutationArgs),
51    /// Queue a time-window change.
52    Time(TimeMutationArgs),
53}
54
55#[derive(Debug, Args)]
56pub struct ScopeArgs {
57    /// Operate on the project-local file instead of the global one.
58    #[arg(long)]
59    pub local: bool,
60    #[arg(long)]
61    pub json: bool,
62}
63
64#[derive(Debug, Args)]
65pub struct PatternMutationArgs {
66    /// Function block to edit. Omit to edit the top-level defaults
67    /// (only valid for services that expose top-level target lists).
68    #[arg(long)]
69    pub function: Option<String>,
70
71    /// Target kind to edit. Must be one of the service's known target
72    /// kinds (`channel`, `user`, `guild`, `chat`, `calendar`,
73    /// `attendee`, `vault`, `item`, `tag`, `category`, `field`).
74    #[arg(long)]
75    pub target: String,
76
77    /// Which list to edit.
78    #[arg(long, value_enum)]
79    pub list: CliListKind,
80
81    /// Pattern value (exact match, glob, `re:<regex>`, or numeric ID).
82    pub value: String,
83
84    #[arg(long)]
85    pub local: bool,
86    #[arg(long)]
87    pub json: bool,
88}
89
90#[derive(Debug, Copy, Clone, ValueEnum)]
91pub enum CliListKind {
92    Allow,
93    Deny,
94}
95
96impl From<CliListKind> for ListKind {
97    fn from(v: CliListKind) -> ListKind {
98        match v {
99            CliListKind::Allow => ListKind::Allow,
100            CliListKind::Deny => ListKind::Deny,
101        }
102    }
103}
104
105#[derive(Debug, Args)]
106pub struct ContentMutationArgs {
107    /// Scope the edit to one function's block. Omit for the top-level
108    /// defaults.
109    #[arg(long)]
110    pub function: Option<String>,
111
112    #[command(subcommand)]
113    pub action: ContentAction,
114
115    #[arg(long)]
116    pub local: bool,
117    #[arg(long)]
118    pub json: bool,
119}
120
121#[derive(Debug, Subcommand)]
122pub enum ContentAction {
123    /// Append a case-insensitive deny-word.
124    AddDenyWord { word: String },
125    /// Remove a deny-word.
126    RemoveDenyWord { word: String },
127    /// Append a deny regex.
128    AddDenyRegex { pattern: String },
129    /// Remove a deny regex.
130    RemoveDenyRegex { pattern: String },
131    /// Set the `max_length` codepoint cap. Pass `--clear` to remove.
132    SetMaxLength {
133        #[arg(long, conflicts_with = "clear")]
134        value: Option<u32>,
135        #[arg(long)]
136        clear: bool,
137    },
138}
139
140#[derive(Debug, Args)]
141pub struct TimeMutationArgs {
142    #[arg(long)]
143    pub function: Option<String>,
144
145    #[command(subcommand)]
146    pub action: TimeAction,
147
148    #[arg(long)]
149    pub local: bool,
150    #[arg(long)]
151    pub json: bool,
152}
153
154#[derive(Debug, Subcommand)]
155pub enum TimeAction {
156    /// Replace the weekday allow-list. Comma-separated: `mon,tue,wed`.
157    SetDays {
158        #[arg(long, value_delimiter = ',')]
159        days: Vec<String>,
160    },
161    /// Replace the HH:MM-HH:MM window list. Comma-separated.
162    SetWindows {
163        #[arg(long, value_delimiter = ',')]
164        windows: Vec<String>,
165    },
166}
167
168// ---------------------------------------------------------------------------
169// dispatch
170// ---------------------------------------------------------------------------
171
172/// Entry point a service's CLI module calls after matching the shared
173/// subcommand.
174pub fn run<S: PermissionsService>(action: StagingAction) -> Result<()> {
175    match action {
176        StagingAction::Status(a) => run_status::<S>(a),
177        StagingAction::Diff(a) => run_diff::<S>(a),
178        StagingAction::Discard(a) => run_discard::<S>(a),
179        StagingAction::Commit(a) => run_commit::<S>(a),
180        StagingAction::Sign(a) => run_sign::<S>(a),
181        StagingAction::Add(a) => run_pattern_mutation::<S>(a, /* add */ true),
182        StagingAction::Remove(a) => run_pattern_mutation::<S>(a, /* add */ false),
183        StagingAction::Content(a) => run_content_mutation::<S>(a),
184        StagingAction::Time(a) => run_time_mutation::<S>(a),
185    }
186}
187
188fn scope_path<S: PermissionsService>(local: bool) -> Result<std::path::PathBuf> {
189    if local {
190        local_path_current::<S>()
191    } else {
192        global_path::<S>()
193    }
194}
195
196#[derive(Debug, Serialize)]
197struct StatusOut {
198    command: String,
199    scope: &'static str,
200    live_path: String,
201    live_exists: bool,
202    pending_path: String,
203    pending_exists: bool,
204}
205
206fn run_status<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
207    let scope = if args.local { "local" } else { "global" };
208    let live = scope_path::<S>(args.local)?;
209    let pending = staging::pending_path_for(&live);
210    let st = staging::status(&live);
211    if args.json {
212        let out = StatusOut {
213            command: format!("{}.permissions.status", S::NAME),
214            scope,
215            live_path: live.display().to_string(),
216            live_exists: st.live_exists,
217            pending_path: pending.display().to_string(),
218            pending_exists: st.pending_exists,
219        };
220        println!("{}", serde_json::to_string_pretty(&out).unwrap());
221    } else {
222        println!("# {} permissions ({scope})", S::NAME);
223        println!(
224            "  live    : {} ({})",
225            live.display(),
226            if st.live_exists { "present" } else { "absent" }
227        );
228        println!(
229            "  pending : {} ({})",
230            pending.display(),
231            if st.pending_exists {
232                "present"
233            } else {
234                "absent"
235            }
236        );
237    }
238    Ok(())
239}
240
241fn run_diff<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
242    let live = scope_path::<S>(args.local)?;
243    match staging::diff(&live)? {
244        Some(body) if body.is_empty() => {
245            println!("# no effective change (pending matches live)");
246        }
247        Some(body) => {
248            print!("{body}");
249        }
250        None => {
251            if args.json {
252                println!(
253                    "{{\"command\": \"{}.permissions.diff\", \"pending\": false}}",
254                    S::NAME
255                );
256            } else {
257                println!("# no pending changes at {}", live.display());
258            }
259        }
260    }
261    Ok(())
262}
263
264fn run_discard<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
265    let live = scope_path::<S>(args.local)?;
266    let removed = staging::discard(&live)?;
267    if args.json {
268        println!(
269            "{{\"command\": \"{}.permissions.discard\", \"removed\": {removed}}}",
270            S::NAME
271        );
272    } else if removed {
273        println!(
274            "Discarded pending changes at {}",
275            staging::pending_path_for(&live).display()
276        );
277    } else {
278        println!("No pending changes to discard.");
279    }
280    Ok(())
281}
282
283fn run_commit<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
284    let live = scope_path::<S>(args.local)?;
285    let key = signing::require_keychain_key()?;
286    staging::commit::<S>(&live, &key)?;
287    if args.json {
288        println!(
289            "{{\"command\": \"{}.permissions.commit\", \"signed_with\": \"{}\"}}",
290            S::NAME,
291            key.fingerprint()
292        );
293    } else {
294        println!(
295            "Committed and signed: {} (key {}).",
296            live.display(),
297            key.fingerprint()
298        );
299    }
300    Ok(())
301}
302
303fn run_sign<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
304    let live = scope_path::<S>(args.local)?;
305    let key = signing::require_keychain_key()?;
306    staging::sign_in_place::<S>(&live, &key)?;
307    if args.json {
308        println!(
309            "{{\"command\": \"{}.permissions.sign\", \"signed_with\": \"{}\"}}",
310            S::NAME,
311            key.fingerprint()
312        );
313    } else {
314        println!(
315            "Signed and trusted: {} (key {}).",
316            live.display(),
317            key.fingerprint()
318        );
319    }
320    Ok(())
321}
322
323fn run_pattern_mutation<S: PermissionsService>(args: PatternMutationArgs, add: bool) -> Result<()> {
324    validate_function::<S>(args.function.as_deref())?;
325    validate_target::<S>(&args.target)?;
326    let mutation = if add {
327        Mutation::AddPattern {
328            function: args.function,
329            target: args.target,
330            list: args.list.into(),
331            value: args.value,
332        }
333    } else {
334        Mutation::RemovePattern {
335            function: args.function,
336            target: args.target,
337            list: args.list.into(),
338            value: args.value,
339        }
340    };
341    queue_and_report::<S>(&mutation, args.local, args.json)
342}
343
344fn run_content_mutation<S: PermissionsService>(args: ContentMutationArgs) -> Result<()> {
345    validate_function::<S>(args.function.as_deref())?;
346    let mutation = match args.action {
347        ContentAction::AddDenyWord { word } => Mutation::AddDenyWord {
348            function: args.function,
349            word,
350        },
351        ContentAction::RemoveDenyWord { word } => Mutation::RemoveDenyWord {
352            function: args.function,
353            word,
354        },
355        ContentAction::AddDenyRegex { pattern } => Mutation::AddDenyRegex {
356            function: args.function,
357            pattern,
358        },
359        ContentAction::RemoveDenyRegex { pattern } => Mutation::RemoveDenyRegex {
360            function: args.function,
361            pattern,
362        },
363        ContentAction::SetMaxLength { value, clear } => {
364            if clear && value.is_some() {
365                return Err(ZadError::Invalid(
366                    "pass --value or --clear, not both".into(),
367                ));
368            }
369            Mutation::SetMaxLength {
370                function: args.function,
371                value: if clear { None } else { value },
372            }
373        }
374    };
375    queue_and_report::<S>(&mutation, args.local, args.json)
376}
377
378fn run_time_mutation<S: PermissionsService>(args: TimeMutationArgs) -> Result<()> {
379    validate_function::<S>(args.function.as_deref())?;
380    let mutation = match args.action {
381        TimeAction::SetDays { days } => Mutation::SetTimeDays {
382            function: args.function,
383            days,
384        },
385        TimeAction::SetWindows { windows } => Mutation::SetTimeWindows {
386            function: args.function,
387            windows,
388        },
389    };
390    queue_and_report::<S>(&mutation, args.local, args.json)
391}
392
393fn queue_and_report<S: PermissionsService>(
394    mutation: &Mutation,
395    local: bool,
396    json: bool,
397) -> Result<()> {
398    let live = scope_path::<S>(local)?;
399    staging::mutate_pending::<S>(&live, mutation)?;
400    let pending = staging::pending_path_for(&live);
401    if json {
402        println!(
403            "{{\"command\": \"{}.permissions.queue\", \"mutation\": {}, \"pending\": \"{}\"}}",
404            S::NAME,
405            serde_json::to_string(mutation).unwrap(),
406            pending.display()
407        );
408    } else {
409        println!("Queued: {}", mutation.summary());
410        println!("  pending: {}", pending.display());
411        println!(
412            "  review with `zad {} permissions diff{}`, commit with \
413             `zad {} permissions commit{}`.",
414            S::NAME,
415            if local { " --local" } else { "" },
416            S::NAME,
417            if local { " --local" } else { "" }
418        );
419    }
420    Ok(())
421}
422
423// ---------------------------------------------------------------------------
424// validation helpers
425// ---------------------------------------------------------------------------
426
427fn validate_function<S: PermissionsService>(function: Option<&str>) -> Result<()> {
428    let Some(name) = function else {
429        return Ok(());
430    };
431    if S::all_functions().contains(&name) {
432        return Ok(());
433    }
434    Err(ZadError::Invalid(format!(
435        "{}: unknown function `{name}`; expected one of {}",
436        S::NAME,
437        S::all_functions().join(", ")
438    )))
439}
440
441fn validate_target<S: PermissionsService>(target: &str) -> Result<()> {
442    if S::target_kinds().contains(&target) {
443        return Ok(());
444    }
445    Err(ZadError::Invalid(format!(
446        "{}: unknown target `{target}`; expected one of {}",
447        S::NAME,
448        S::target_kinds().join(", ")
449    )))
450}