1use std::io::BufRead;
2use std::sync::Arc;
3
4use clap::Subcommand;
5
6use crate::admin::{self, DeleteOutcome};
7use crate::cli::CliError;
8use crate::cli::render;
9use crate::cli::style::Palette;
10use crate::cli::window::{DEFAULT_LIMIT, Window};
11use crate::config::Config;
12use crate::sqlite::account::Account;
13use crate::sqlite::db::Database;
14
15#[derive(Subcommand)]
16pub enum AccountCommand {
17 List {
19 #[arg(long)]
21 profile: Option<String>,
22 #[arg(long, default_value_t = DEFAULT_LIMIT)]
23 limit: i64,
24 #[arg(long, default_value_t = 0)]
25 offset: i64,
26 #[arg(long)]
27 json: bool,
28 },
29 Show {
31 id: String,
32 #[arg(long)]
33 json: bool,
34 },
35 UpdateContact {
37 id: String,
38 #[arg(long = "contact")]
39 contact: Vec<String>,
40 },
41 Deactivate { id: String },
43 Delete { id: String },
45}
46
47pub async fn run_account_command(
48 command: AccountCommand,
49 yes: bool,
50 palette: Palette,
51 reader: &mut impl BufRead,
52 config: &Config,
53 database: Arc<Database>,
54) -> Result<(), CliError> {
55 match command {
56 AccountCommand::List {
57 profile,
58 limit,
59 offset,
60 json,
61 } => {
62 let window = Window::resolve(limit, offset);
63 let (accounts, total) =
64 Account::search(profile.as_deref(), window.limit, window.offset, &database).await?;
65 render::print_page(
66 &accounts,
67 total,
68 window,
69 json,
70 |a| admin::render_account_json(a, &config.server.base_url),
71 |a| render::render_account_line(a, palette),
72 );
73 }
74 AccountCommand::Show { id, json } => match Account::find_any_by_id(&id, &database).await? {
75 None => return Err(not_found(&id)),
76 Some(account) if json => {
77 println!(
78 "{}",
79 admin::render_account_json(&account, &config.server.base_url)
80 );
81 }
82 Some(account) => print!("{}", render::render_account_detail_text(&account, palette)),
83 },
84 AccountCommand::UpdateContact { id, contact } => {
85 match admin::update_account_contact(&id, contact, database).await? {
86 None => return Err(not_found(&id)),
87 Some(account) => println!("{}", render::render_account_line(&account, palette)),
88 }
89 }
90 AccountCommand::Deactivate { id } => {
91 match admin::deactivate_account(&id, database).await? {
92 None => return Err(not_found(&id)),
93 Some(account) => println!("{}", render::render_account_line(&account, palette)),
94 }
95 }
96 AccountCommand::Delete { id } => {
97 match admin::confirm_delete_account(&id, yes, reader, database).await? {
98 DeleteOutcome::NotFound => return Err(not_found(&id)),
99 DeleteOutcome::Cancelled => println!("Cancelled."),
100 DeleteOutcome::Deleted => println!("Deleted account {id}."),
101 }
102 }
103 }
104 Ok(())
105}
106
107fn not_found(id: &str) -> CliError {
108 CliError(format!("no such account: {id}"))
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::audit::ClientContext;
115
116 #[tokio::test]
119 async fn every_arm_refuses_an_unknown_account() {
120 let database = Arc::new(Database::connect_in_memory().await.unwrap());
121 let config = Config::default();
122 let expected = CliError("no such account: acct-nope".to_string());
123
124 let commands = vec![
125 AccountCommand::Show {
126 id: "acct-nope".to_string(),
127 json: false,
128 },
129 AccountCommand::UpdateContact {
130 id: "acct-nope".to_string(),
131 contact: vec!["mailto:someone@example.com".to_string()],
132 },
133 AccountCommand::Deactivate {
134 id: "acct-nope".to_string(),
135 },
136 AccountCommand::Delete {
137 id: "acct-nope".to_string(),
138 },
139 ];
140 for command in commands {
141 let mut reader: &[u8] = &[];
142 let error = run_account_command(
143 command,
144 true,
145 Palette::plain(),
146 &mut reader,
147 &config,
148 database.clone(),
149 )
150 .await
151 .expect_err("an unknown account must fail");
152 assert_eq!(error, expected);
153 }
154 }
155
156 #[tokio::test]
159 async fn a_declined_delete_is_not_a_failure() {
160 let database = Arc::new(Database::connect_in_memory().await.unwrap());
161 let config = Config::default();
162 let (account, _) = Account::find_or_create(
163 "default",
164 &[7, 7, 7],
165 vec![],
166 &ClientContext::default(),
167 &database,
168 )
169 .await
170 .unwrap();
171
172 let mut reader: &[u8] = b"n\n";
173 run_account_command(
174 AccountCommand::Delete {
175 id: account.id.clone(),
176 },
177 false,
178 Palette::plain(),
179 &mut reader,
180 &config,
181 database.clone(),
182 )
183 .await
184 .unwrap();
185
186 assert!(
187 Account::find_any_by_id(&account.id, &database)
188 .await
189 .unwrap()
190 .is_some(),
191 "a declined delete must leave the account in place"
192 );
193 }
194
195 #[tokio::test]
200 async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
201 let database = Arc::new(Database::connect_in_memory().await.unwrap());
202 let config = Config::default();
203 for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
204 Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
205 .await
206 .unwrap();
207 }
208
209 let mut reader: &[u8] = &[];
210 for (limit, offset, json) in [(2, 0, false), (2, 2, false), (2, 0, true), (0, -5, false)] {
211 run_account_command(
212 AccountCommand::List {
213 profile: None,
214 limit,
215 offset,
216 json,
217 },
218 true,
219 Palette::plain(),
220 &mut reader,
221 &config,
222 database.clone(),
223 )
224 .await
225 .unwrap_or_else(|error| panic!("--limit {limit} --offset {offset}: {error}"));
226 }
227 }
228
229 #[tokio::test]
234 async fn consecutive_pages_do_not_overlap_and_the_total_stays_unpaged() {
235 let database = Arc::new(Database::connect_in_memory().await.unwrap());
236 for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
237 Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
238 .await
239 .unwrap();
240 }
241
242 let (first, total) = Account::search(None, 2, 0, &database).await.unwrap();
243 let (second, also_total) = Account::search(None, 2, 2, &database).await.unwrap();
244
245 assert_eq!(total, 3);
246 assert_eq!(also_total, 3, "the total is the table, not the page");
247 assert_eq!(first.len(), 2);
248 assert_eq!(second.len(), 1);
249 for account in &second {
250 assert!(
251 !first.iter().any(|earlier| earlier.id == account.id),
252 "a row appeared on two pages"
253 );
254 }
255 }
256
257 #[tokio::test]
260 async fn the_json_arms_render() {
261 let database = Arc::new(Database::connect_in_memory().await.unwrap());
262 let config = Config::default();
263 let (account, _) = Account::find_or_create(
264 "default",
265 &[9, 9, 9],
266 vec![],
267 &ClientContext::default(),
268 &database,
269 )
270 .await
271 .unwrap();
272
273 let mut reader: &[u8] = &[];
274 run_account_command(
275 AccountCommand::List {
276 profile: Some("default".to_string()),
277 limit: DEFAULT_LIMIT,
278 offset: 0,
279 json: true,
280 },
281 true,
282 Palette::plain(),
283 &mut reader,
284 &config,
285 database.clone(),
286 )
287 .await
288 .unwrap();
289
290 run_account_command(
291 AccountCommand::Show {
292 id: account.id,
293 json: true,
294 },
295 true,
296 Palette::plain(),
297 &mut reader,
298 &config,
299 database,
300 )
301 .await
302 .unwrap();
303 }
304}