1use std::io::Write;
10
11use anyhow::{anyhow, Context, Result};
12use clap::{Args, Subcommand};
13use serde_json::{json, Value};
14
15use crate::client;
16use crate::commands::resolve_repo_slug;
17use crate::output::{render_record, render_table, FormatChoice};
18
19#[derive(Debug, Subcommand)]
20pub enum RepoCmd {
21 List(ListArgs),
23 View(ViewArgs),
25 Clone(CloneArgs),
27}
28
29#[derive(Debug, Args)]
30pub struct ListArgs {
31 #[arg(long)]
33 pub all: bool,
34 #[arg(long, default_value_t = false)]
35 pub json: bool,
36 #[arg(long, default_value_t = false)]
37 pub tsv: bool,
38}
39
40#[derive(Debug, Args)]
41pub struct ViewArgs {
42 pub slug: String,
44 #[arg(long, default_value_t = false)]
45 pub json: bool,
46 #[arg(long, default_value_t = false)]
47 pub tsv: bool,
48}
49
50#[derive(Debug, Args)]
51pub struct CloneArgs {
52 pub slug: String,
54 pub dest: Option<String>,
56}
57
58pub fn run(cmd: RepoCmd) -> Result<()> {
59 match cmd {
60 RepoCmd::List(a) => list(a),
61 RepoCmd::View(a) => view(a),
62 RepoCmd::Clone(a) => clone(a),
63 }
64}
65
66fn list(args: ListArgs) -> Result<()> {
67 let (cli, cfg) = client::authenticated()?;
68 let data = cli.execute(LIST_REPOS, json!({}))?;
69 let repos = data["repositories"]
70 .as_array()
71 .ok_or_else(|| anyhow!("repositories field missing"))?;
72 let rows = repo_rows(repos, &cfg.username, args.all);
73 let mut stdout = std::io::stdout().lock();
74 let fmt = FormatChoice {
75 json: args.json,
76 tsv: args.tsv,
77 }
78 .resolve();
79 stdout
80 .write_all(render_table(&["repo", "visibility", "description"], &rows, fmt).as_bytes())?;
81 Ok(())
82}
83
84fn view(args: ViewArgs) -> Result<()> {
85 let (cli, _cfg) = client::authenticated()?;
86 let (owner, name) = resolve_repo_slug(&args.slug)?;
87 let data = cli.execute(LIST_REPOS, json!({}))?;
88 let repos = data["repositories"]
89 .as_array()
90 .ok_or_else(|| anyhow!("repositories field missing"))?;
91 let r = repos
92 .iter()
93 .find(|r| r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name))
94 .ok_or_else(|| anyhow!("no repository named {}/{}", owner, name))?;
95 let pairs = repo_record_pairs(r, &owner, &name);
96 let mut stdout = std::io::stdout().lock();
97 let fmt = FormatChoice {
98 json: args.json,
99 tsv: args.tsv,
100 }
101 .resolve();
102 stdout.write_all(render_record(&pairs, fmt).as_bytes())?;
103 Ok(())
104}
105
106fn clone(args: CloneArgs) -> Result<()> {
107 let (cli, _cfg) = client::authenticated()?;
108 let (owner, name) = crate::commands::parse_slug(&args.slug)?;
111 let url = format!("{}/{}/{}.git", cli.host(), owner, name);
112 let dest = args.dest.unwrap_or_else(|| name.clone());
113 let status = std::process::Command::new("git")
114 .arg("clone")
115 .arg(&url)
116 .arg(&dest)
117 .status()
118 .context("run `git clone`")?;
119 if !status.success() {
120 return Err(anyhow!("git clone exited with status {status}"));
121 }
122 Ok(())
123}
124
125const LIST_REPOS: &str =
126 "{ repositories { id ownerSlug name description visibility defaultBranch updatedAt } }";
127
128fn repo_rows(repos: &[Value], username: &str, include_all: bool) -> Vec<Vec<String>> {
129 repos
130 .iter()
131 .filter(|r| include_all || r["ownerSlug"].as_str() == Some(username))
132 .map(|r| {
133 vec![
134 format!(
135 "{}/{}",
136 r["ownerSlug"].as_str().unwrap_or(""),
137 r["name"].as_str().unwrap_or("")
138 ),
139 r["visibility"].as_str().unwrap_or("").to_string(),
140 r["description"].as_str().unwrap_or("").to_string(),
141 ]
142 })
143 .collect()
144}
145
146fn repo_record_pairs(repo: &Value, owner: &str, name: &str) -> Vec<(&'static str, String)> {
147 vec![
148 ("Repo", format!("{owner}/{name}")),
149 (
150 "Visibility",
151 repo["visibility"].as_str().unwrap_or("").to_string(),
152 ),
153 (
154 "Default branch",
155 repo["defaultBranch"].as_str().unwrap_or("").to_string(),
156 ),
157 (
158 "Description",
159 repo["description"].as_str().unwrap_or("").to_string(),
160 ),
161 (
162 "Updated",
163 repo["updatedAt"].as_str().unwrap_or("").to_string(),
164 ),
165 ]
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn repo_rows_filter_to_viewer_unless_all_is_set() {
174 let repos = vec![
175 json!({
176 "ownerSlug": "bri",
177 "name": "runtime",
178 "visibility": "PUBLIC",
179 "description": "Runtime platform"
180 }),
181 json!({
182 "ownerSlug": "runtime-labs",
183 "name": "ops",
184 "visibility": "PRIVATE",
185 "description": "Operations"
186 }),
187 json!({}),
188 ];
189
190 assert_eq!(
191 repo_rows(&repos, "bri", false),
192 vec![vec!["bri/runtime", "PUBLIC", "Runtime platform"]]
193 );
194 assert_eq!(
195 repo_rows(&repos, "bri", true),
196 vec![
197 vec!["bri/runtime", "PUBLIC", "Runtime platform"],
198 vec!["runtime-labs/ops", "PRIVATE", "Operations"],
199 vec!["/", "", ""],
200 ]
201 );
202 }
203
204 #[test]
205 fn repo_record_pairs_format_defaults_without_leaking_api_shape() {
206 let repo = json!({
207 "visibility": "PRIVATE",
208 "defaultBranch": "trunk",
209 "description": "A repo",
210 "updatedAt": "2026-06-18T01:00:00Z"
211 });
212
213 assert_eq!(
214 repo_record_pairs(&repo, "bri", "runtime"),
215 vec![
216 ("Repo", "bri/runtime".to_string()),
217 ("Visibility", "PRIVATE".to_string()),
218 ("Default branch", "trunk".to_string()),
219 ("Description", "A repo".to_string()),
220 ("Updated", "2026-06-18T01:00:00Z".to_string()),
221 ]
222 );
223
224 assert_eq!(
225 repo_record_pairs(&json!({}), "owner", "repo"),
226 vec![
227 ("Repo", "owner/repo".to_string()),
228 ("Visibility", String::new()),
229 ("Default branch", String::new()),
230 ("Description", String::new()),
231 ("Updated", String::new()),
232 ]
233 );
234 }
235}