1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//! CLI arguments (PLAN.md milestone 8) + the provider manager
//! subcommand tree (plans/0010 M3).
use clap::{Parser, Subcommand};
use std::path::PathBuf;
/// Browse GitHub repos in your terminal — a yazi-like, modal TUI.
#[derive(Debug, Parser)]
#[command(name = "rootle", version, about)]
pub struct Cli {
/// Open this repo directly (owner/repo), skipping the search popup.
pub repo: Option<String>,
/// Use this config file instead of ~/.config/rootle/config.toml.
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Theme name, resolved against ~/.config/rootle/themes/<name>.toml
/// (missing keys fall back to the embedded catppuccin-mocha).
#[arg(long, value_name = "NAME")]
pub theme: Option<String>,
/// Provider management (plans/0010): install, list, update,
/// upgrade, pin, remove, use.
#[command(subcommand)]
pub provider: Option<ProviderCommand>,
}
/// Install a provider from a GitHub release, manage it locally, and
/// switch the active backend.
#[derive(Debug, Subcommand)]
pub enum ProviderCommand {
/// Install a provider binary from a release or a plain-HTTP tarball.
///
/// REF: `gitlab` (bare name, resolves to rootledev/rootle-gitlab),
/// `owner/repo`, `https://github.com/owner/repo`, each optionally
/// `@tag`-pinned — or a plain-HTTP URL naming the platform tarball
/// (`…/rootle-NAME[-vX.Y.Z]-TARGET.tar.gz`; the mandatory `.sha256`
/// sidecar rides next to it). Checksum verification is mandatory.
///
/// Plain-HTTP and `--path` installs are install-and-pin:
/// `update`/`upgrade` track only releases-API sources (github.com
/// slugs/URLs); upgrades for the rest come from whatever deployed
/// them. `--path` is a first-class deployment shape — the steady
/// state for config-managed installs.
Install {
ref_: String,
/// Pin to the given tag (or the latest when already @tag'd).
#[arg(long)]
pin: bool,
/// Reinstall even when the version is already present.
#[arg(long)]
force: bool,
/// Local binary path — symlink, no network (development).
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
},
/// List installed providers.
List {
/// JSON for scripting.
#[arg(long)]
json: bool,
},
/// Refresh latest-known release tags (non-mutating).
Update {
/// A specific provider (default: all).
name: Option<String>,
},
/// Upgrade provider binaries to the latest release.
Upgrade {
/// A specific provider, or --all.
name: Option<String>,
#[arg(long)]
all: bool,
/// Print old → new without swapping.
#[arg(long)]
dry_run: bool,
/// Upgrade pinned providers too.
#[arg(long)]
force: bool,
},
/// Freeze a provider at its current (or given) tag.
Pin { name: String, tag: Option<String> },
/// Unfreeze a provider (resume auto-upgrades).
Unpin { name: String },
/// Remove a provider binary and its receipt.
Remove { name: String },
/// Activate a provider: writes [provider] in config.toml.
///
/// Extra argv after -- reaches the provider binary (e.g. `use
/// gitlab -- --instance https://gitlab.example.com`).
Use {
name: String,
#[arg(last = true)]
extra: Vec<String>,
},
}
impl Cli {
/// Split the positional repo argument into (owner, name), if valid.
pub fn repo_parts(&self) -> Option<(String, String)> {
let repo = self.repo.as_ref()?;
let (owner, name) = repo.split_once('/')?;
if owner.is_empty() || name.is_empty() {
return None;
}
Some((owner.to_string(), name.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cli(repo: Option<&str>) -> Cli {
Cli {
repo: repo.map(str::to_string),
config: None,
theme: None,
provider: None,
}
}
#[test]
fn parses_owner_repo() {
assert_eq!(
cli(Some("ratatui/ratatui")).repo_parts(),
Some(("ratatui".into(), "ratatui".into()))
);
}
#[test]
fn rejects_malformed_repo() {
assert_eq!(cli(Some("noslash")).repo_parts(), None);
assert_eq!(cli(Some("/empty")).repo_parts(), None);
assert_eq!(cli(None).repo_parts(), None);
}
#[test]
fn parses_provider_subcommands() {
use clap::Parser;
let c = Cli::try_parse_from(["rootle", "provider", "list"]).unwrap();
assert!(matches!(
c.provider,
Some(ProviderCommand::List { json: false })
));
let c = Cli::try_parse_from(["rootle", "provider", "install", "gitlab", "--pin"]).unwrap();
assert!(matches!(
c.provider,
Some(ProviderCommand::Install { ref_, pin: true, .. }) if ref_ == "gitlab"
));
let c = Cli::try_parse_from([
"rootle",
"provider",
"use",
"gitlab",
"--",
"--instance",
"https://gitlab.example.com",
])
.unwrap();
assert!(matches!(
c.provider,
Some(ProviderCommand::Use { name, extra, .. }) if name == "gitlab" && extra.len() == 2
));
}
}