aristo-cli 0.7.0

Aristo CLI binary (the `aristo` command).
Documentation
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
//! `aristo auth {login, status, logout}` — credential lifecycle.
//!
//! Wires `aristo_core::canon::auth` into the CLI dispatcher. The
//! handlers are intentionally thin: token-resolution and
//! persistence live in the library so other binaries (eventually a
//! `aretta-admin` clone or scripted tooling) can call them
//! directly without going through the CLI.
//!
//! ## Login flow
//!
//! `aristo auth login --server <url> --repo <owner/repo>` is the GitHub
//! OAuth flow: the CLI fetches the authorize URL from the server, the
//! user pastes the code shown on the callback page, the server mints an
//! `arta_*` token scoped to `(user, repo)`, and the CLI stores it keyed
//! by server and repo. CI and scripts read `ARETTA_TOKEN` +
//! `ARETTA_API_URL` from the environment and never touch the store.

use std::path::Path;

use aristo_core::auth::{
    self, derive_repo_full_name, login_command, login_server, AuthError, CredentialEntry,
    CredentialStore, LoginServerSource, ServerUrl, Token, UpsertOutcome, UpsertReport,
};

use crate::{AuthAction, CliError, CliResult};

/// Dispatcher for `aristo auth` subcommands.
pub(crate) fn run(action: AuthAction) -> CliResult<()> {
    match action {
        AuthAction::Login { server, repo } => login(server, repo),
        AuthAction::Status => status(),
        AuthAction::Token { repo } => token(repo),
        AuthAction::Logout { all, repo } => logout(all, repo),
    }
}

// ─── login ─────────────────────────────────────────────────────────────────

fn login(server_flag: Option<String>, repo_flag: Option<String>) -> CliResult<()> {
    // The two things a token is scoped by, both required: the server
    // it is minted against (`--server`, else `ARETTA_API_URL`) and the
    // repo (`--repo`, else the checkout's origin). The platform apex
    // cannot mint an org token, so the server is never guessed.
    let env_override = std::env::var("ARETTA_API_URL").ok();
    let (server, source) = login_server(server_flag.as_deref(), env_override.as_deref())
        .ok_or_else(|| CliError::Other {
            message: "no server given.\n  \
                      Pass `--server https://<org>.aretta.ai` (your Aretta dashboard's hostname), \
                      or set ARETTA_API_URL."
                .into(),
            exit_code: 2,
        })?;
    let repo_full_name = resolve_repo_full_name(repo_flag)?;
    login_via_oauth(&server, source, repo_full_name)
}

fn login_via_oauth(
    server: &ServerUrl,
    source: LoginServerSource,
    repo_full_name: String,
) -> CliResult<()> {
    // 1. Fetch the GitHub OAuth URL from the proxy.
    let init = auth::oauth_start(server).map_err(auth_error_to_cli)?;

    // 2. Show the URL + try to open the browser. Name where the server
    //    came from, so a stale ARETTA_API_URL export is visible before
    //    the user authorizes.
    eprintln!();
    eprintln!("Authenticating against {server} ({})", source.provenance());
    eprintln!("Scoping token to repo: {repo_full_name}");
    eprintln!();
    eprintln!("Open this URL to authorize with GitHub:");
    eprintln!();
    eprintln!("    {}", init.authorize_url);
    eprintln!();
    let _ = try_open_browser(&init.authorize_url);
    eprintln!("After authorizing, the page will display a code. Paste it here:");

    // 3. Read the code from stdin (one line).
    let mut line = String::new();
    std::io::stdin()
        .read_line(&mut line)
        .map_err(CliError::Io)?;
    let code = line.trim();
    if code.is_empty() {
        return Err(CliError::Other {
            message: "no OAuth code provided. Re-run `aristo auth login` and paste the code from the callback page.".into(),
            exit_code: 2,
        });
    }

    // 4. Exchange the code for an arta_* token.
    let resp = auth::oauth_exchange(server, code, &repo_full_name, Some("aristo-cli"))
        .map_err(auth_error_to_cli)?;

    // 5. Persist the full credentials record (token + server + user + repo).
    let token = Token::new(&resp.arta_token);
    let creds = aristo_core::auth::CredentialsRecord {
        token,
        server: server.clone(),
        user_login: Some(resp.user.login.clone()),
        user_id: Some(resp.user.id),
        repo: Some(resp.repo_full_name.clone()),
    };
    let report = aristo_core::auth::save_full(&creds).map_err(CliError::Io)?;

    let path = auth::credentials_path().map_err(auth_error_to_cli)?;
    println!(
        "ok: authenticated as {} for {}",
        resp.user.login, resp.repo_full_name
    );
    println!("    token saved to {}", path.display());
    print_login_report(&report, &creds.token)?;
    println!("    `aristo auth status` to verify; `aristo auth logout` to remove.");
    Ok(())
}

// ─── what the store now holds, and what THIS directory resolves to ─────────

/// After a login: what happened to the store and whether the directory
/// the user is standing in will actually use the new entry. Both are
/// answered from the store as saved — no second read — and the
/// resolution verdict goes through the resolver's own selection rule.
fn print_login_report(report: &UpsertReport, saved_token: &Token) -> CliResult<()> {
    let saved = report
        .store
        .entries
        .iter()
        .find(|e| e.token.as_str() == saved_token.as_str())
        .ok_or_else(|| CliError::Other {
            message: "internal: the saved credential is not in the store just written".into(),
            exit_code: 1,
        })?;
    println!(
        "    {}",
        store_change_line(report.outcome, saved, report.store.len())
    );
    let cwd = std::env::current_dir().map_err(CliError::Io)?;
    println!("    {}", login_verdict(&report.store, saved, &cwd));
    if std::env::var(auth::ENV_VAR).is_ok_and(|v| !v.trim().is_empty()) {
        println!(
            "    note: {} is set in the environment; it takes precedence over the saved entry.",
            auth::ENV_VAR
        );
    }
    Ok(())
}

/// `entry added: server …, repo … — N entries on file.` (or `replaced`,
/// naming how many older entries for the repo were dropped).
fn store_change_line(outcome: UpsertOutcome, saved: &CredentialEntry, total: usize) -> String {
    let what = match outcome {
        UpsertOutcome::Added => "entry added".to_string(),
        UpsertOutcome::Replaced { dropped } => format!(
            "entry replaced (dropped {dropped} older {} for this repo)",
            plural(dropped, "entry", "entries")
        ),
    };
    format!(
        "{what}: {}{total} {} on file.",
        entry_key(saved),
        plural(total, "entry", "entries")
    )
}

/// The `(server, repo)` key of an entry, as shown to the user.
fn entry_key(e: &CredentialEntry) -> String {
    format!(
        "server {}, repo {}",
        e.server,
        e.repo.as_deref().unwrap_or("(unscoped)")
    )
}

fn plural(n: usize, one: &str, many: &str) -> String {
    if n == 1 { one } else { many }.to_string()
}

/// The entry the resolver will pick when run from `dir`, alongside the
/// derived checkout (or why none could be derived). One call site for
/// the rule, so login, status and the resolver agree.
fn resolution_at<'s>(
    store: &'s CredentialStore,
    dir: &Path,
) -> (Result<String, String>, Option<&'s CredentialEntry>) {
    let checkout = auth::checkout_at(dir);
    let picked = store.resolve_for(checkout.as_deref().ok());
    (checkout, picked)
}

/// Will running aristo from `dir` use `saved`? One line, with the
/// remedy when the answer is no.
fn login_verdict(store: &CredentialStore, saved: &CredentialEntry, dir: &Path) -> String {
    let (checkout, picked) = resolution_at(store, dir);
    let uses_saved = picked.is_some_and(|e| e.token.as_str() == saved.token.as_str());
    let remedy = match saved.repo.as_deref() {
        Some(repo) => format!(
            "run aristo from a {repo} checkout, or set ARETTA_TOKEN=$(aristo auth token --repo {repo})."
        ),
        None => "set ARETTA_TOKEN to use it.".to_string(),
    };
    match (checkout, uses_saved) {
        (Ok(repo), true) => format!("this checkout ({repo}) resolves to this entry."),
        (Ok(repo), false) => {
            format!("this checkout ({repo}) will NOT resolve to this entry — {remedy}")
        }
        (Err(why), _) => format!(
            "this directory is not a GitHub checkout ({why}); nothing resolves here — {remedy}"
        ),
    }
}

/// `aristo auth status`' view of the same question: which stored entry
/// a run from `dir` will use, or the one-line fix if none.
fn status_verdict(store: &CredentialStore, dir: &Path) -> String {
    let (checkout, picked) = resolution_at(store, dir);
    match (checkout, picked) {
        (Ok(repo), Some(e)) => format!("this checkout ({repo}) resolves to: {}", entry_key(e)),
        (Ok(repo), None) => format!(
            "this checkout ({repo}) resolves to: no stored credential — \
             run `{}` here, or set ARETTA_TOKEN + ARETTA_API_URL.",
            login_command(Some(&repo))
        ),
        (Err(why), _) => format!(
            "this directory is not a GitHub checkout ({why}) — resolves to: no stored \
             credential ({} on file; run from a checkout of one of them, or set ARETTA_TOKEN).",
            store.len()
        ),
    }
}

/// Validate a `--repo owner/repo` flag value.
fn validate_repo_flag(raw: &str) -> CliResult<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(CliError::Other {
            message: "--repo must be `owner/repo` (got empty string)".into(),
            exit_code: 2,
        });
    }
    if !trimmed.contains('/') {
        return Err(CliError::Other {
            message: format!("--repo `{trimmed}` is not in `owner/repo` form"),
            exit_code: 2,
        });
    }
    Ok(trimmed.to_string())
}

/// Resolve `owner/repo`, requiring one: the `--repo` flag, else the
/// cwd's git remote (erroring with a `--repo` hint if neither works).
fn resolve_repo_full_name(repo_flag: Option<String>) -> CliResult<String> {
    if let Some(r) = repo_flag {
        return validate_repo_flag(&r);
    }
    let cwd = std::env::current_dir().map_err(CliError::Io)?;
    derive_repo_full_name(&cwd).map_err(auth_error_to_cli)
}

/// Resolve `owner/repo` best-effort: the `--repo` flag (validated), else
/// the cwd's git remote, else `None`. Used where a missing repo is
/// acceptable (a raw-token paste, or a repo-scoped lookup that renders
/// its own "which repo?" error).
fn resolve_repo_best_effort(repo_flag: Option<String>) -> CliResult<Option<String>> {
    if let Some(r) = repo_flag {
        return Ok(Some(validate_repo_flag(&r)?));
    }
    Ok(std::env::current_dir()
        .ok()
        .and_then(|cwd| derive_repo_full_name(&cwd).ok()))
}

fn try_open_browser(url: &str) -> std::io::Result<()> {
    // Test mode: e2e tests spawn the real aristo binary and would
    // otherwise launch the developer's browser on every test run.
    // The `ARISTO_NO_BROWSER` env var suppresses the spawn. Set it
    // in tests + any CI that doesn't want browser pop-ups.
    if std::env::var("ARISTO_NO_BROWSER").is_ok() {
        return Ok(());
    }
    let cmd = if cfg!(target_os = "macos") {
        "open"
    } else if cfg!(target_os = "windows") {
        "start"
    } else {
        "xdg-open"
    };
    std::process::Command::new(cmd)
        .arg(url)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .map(|_| ())
}

fn auth_error_to_cli(e: AuthError) -> CliError {
    CliError::Other {
        message: e.to_string(),
        exit_code: 1,
    }
}

/// Map a store-load error into a CLI error with a recovery hint. The
/// store loader only ever returns `Malformed` (empty is `Ok`), but any
/// other variant is mapped defensively.
fn store_error_to_cli(e: AuthError) -> CliError {
    match e {
        AuthError::Malformed(msg) => CliError::Other {
            message: format!(
                "credentials file is malformed: {msg}\n  \
                 Run `aristo auth logout --all`, then `{}` to re-create it.",
                login_command(None)
            ),
            exit_code: 1,
        },
        other => auth_error_to_cli(other),
    }
}

/// A trailing note when `ARETTA_TOKEN` is set — it overrides the store,
/// so removing entries doesn't stop canon calls from using it.
fn note_env_still_set() {
    if std::env::var(auth::ENV_VAR).is_ok() {
        println!(
            "    note: {} is set in the environment; canon calls will still use it.",
            auth::ENV_VAR
        );
    }
}

// ─── status ────────────────────────────────────────────────────────────────

fn status() -> CliResult<()> {
    // Exit code mirrors the verdict: 0 iff a run from this directory
    // would authenticate (env token with its server, or a stored entry
    // for this checkout). Everything printed is token-free.
    let env_token_set = std::env::var(auth::ENV_VAR).is_ok_and(|v| !v.trim().is_empty());
    let env_server = std::env::var(auth::SERVER_ENV_VAR)
        .ok()
        .filter(|v| !v.trim().is_empty());

    let store = auth::load_store().map_err(store_error_to_cli)?;
    let path = auth::credentials_path().map_err(auth_error_to_cli)?;
    let cwd = std::env::current_dir().map_err(CliError::Io)?;

    let resolves = if env_token_set {
        match env_server {
            Some(server) => {
                println!(
                    "ok: authenticated via {} for {} ({} takes precedence over every stored entry).",
                    auth::ENV_VAR,
                    ServerUrl::parse(&server),
                    auth::ENV_VAR
                );
                true
            }
            None => {
                println!("not authenticated: {}", AuthError::EnvTokenWithoutServer);
                false
            }
        }
    } else if store.is_empty() {
        println!("{}", AuthError::NoToken);
        false
    } else {
        let (checkout, picked) = resolution_at(&store, &cwd);
        println!(
            "{}: {} credential(s) in {}",
            if picked.is_some() {
                "ok: authenticated"
            } else {
                "not authenticated for this checkout"
            },
            store.len(),
            path.display()
        );
        for e in &store.entries {
            println!("{}", e.summary());
        }
        println!("    {}", status_verdict(&store, &cwd));
        let _ = checkout;
        picked.is_some()
    };

    if env_token_set && !store.is_empty() {
        println!(
            "    also stored (shadowed by {}): {} credential(s) in {}",
            auth::ENV_VAR,
            store.len(),
            path.display()
        );
    }
    if resolves {
        Ok(())
    } else {
        Err(CliError::Silent { exit_code: 1 })
    }
}

// ─── token ─────────────────────────────────────────────────────────────────

/// Print the resolved token to stdout — and NOTHING else — so it pipes
/// cleanly into a clipboard tool (`aristo auth token | pbcopy`) or a CI
/// secret. Unlike `status`, this deliberately prints the secret value, so
/// it's only ever written to stdout on explicit request. Resolves the
/// entry for `--repo` (or the cwd's repo); nothing else.
fn token(repo_flag: Option<String>) -> CliResult<()> {
    // Env var wins outright (CI precedence), like `resolve`.
    if let Ok(v) = std::env::var(auth::ENV_VAR) {
        let v = v.trim();
        if !v.is_empty() {
            println!("{v}");
            return Ok(());
        }
    }
    let store = auth::load_store().map_err(store_error_to_cli)?;
    if store.is_empty() {
        return Err(CliError::Other {
            message: AuthError::NoToken.to_string(),
            exit_code: 1,
        });
    }
    // `--repo` or the cwd's repo, matched strictly — asking for a repo
    // you're not logged in to errors rather than silently handing back
    // a different repo's token.
    let entry = if let Some(raw) = repo_flag {
        let repo = validate_repo_flag(&raw)?;
        match store.find_by_repo(&repo) {
            Some(e) => Some(e),
            None => {
                return Err(CliError::Other {
                    message: format!(
                        "no credential for {repo}; run `{}` \
                         (or `aristo auth status` to list what's stored).",
                        login_command(Some(&repo))
                    ),
                    exit_code: 1,
                })
            }
        }
    } else {
        let cwd_repo = std::env::current_dir()
            .ok()
            .and_then(|cwd| derive_repo_full_name(&cwd).ok());
        cwd_repo.as_deref().and_then(|r| store.find_by_repo(r))
    };
    match entry {
        Some(e) => {
            println!("{}", e.token.as_str());
            Ok(())
        }
        None => Err(CliError::Other {
            message: "no credential for this checkout — pass `--repo <owner/repo>` to pick one \
                      (or `aristo auth status` to list what's stored)."
                .into(),
            exit_code: 1,
        }),
    }
}

// ─── logout ────────────────────────────────────────────────────────────────

fn logout(all: bool, repo_flag: Option<String>) -> CliResult<()> {
    let path = auth::credentials_path().map_err(auth_error_to_cli)?;

    // `--all`: remove the whole file. Works even on a corrupt file.
    if all {
        let existed = path.exists();
        auth::clear().map_err(CliError::Io)?;
        if existed {
            println!(
                "ok: logged out. all credentials cleared from {}",
                path.display()
            );
        } else {
            println!("ok: not logged in (no credentials to clear).");
        }
        note_env_still_set();
        return Ok(());
    }

    let mut store = auth::load_store().map_err(|e| match e {
        AuthError::Malformed(msg) => CliError::Other {
            message: format!(
                "credentials file is malformed: {msg}\n  \
                 Run `aristo auth logout --all` to reset it."
            ),
            exit_code: 1,
        },
        other => auth_error_to_cli(other),
    })?;
    if store.is_empty() {
        println!("ok: not logged in (no credentials to clear).");
        note_env_still_set();
        return Ok(());
    }

    // Which entry? `--repo`, else the cwd's repo. Nothing else — the
    // same rule the resolver uses to pick an entry.
    let Some(repo) = resolve_repo_best_effort(repo_flag)? else {
        return Err(CliError::Other {
            message: "not a GitHub checkout — pass `--repo <owner/repo>` to log out of one \
                      credential, or `--all` to clear everything."
                .into(),
            exit_code: 2,
        });
    };
    if store.remove_by_repo(&repo) == 0 {
        println!("ok: no credential for {repo} to remove (nothing changed).");
        note_env_still_set();
        return Ok(());
    }
    let removed_label = format!("of {repo}");

    // Persist: drop the file when the store is now empty, else rewrite it.
    if store.is_empty() {
        auth::clear().map_err(CliError::Io)?;
    } else {
        auth::save_store(&store).map_err(CliError::Io)?;
    }
    println!("ok: logged out {removed_label}. updated {}", path.display());
    note_env_still_set();
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn entry(server: &str, repo: Option<&str>, token: &str) -> CredentialEntry {
        CredentialEntry::bare(
            Token::new(token),
            ServerUrl::parse(server),
            repo.map(str::to_string),
        )
    }

    fn store(entries: Vec<CredentialEntry>) -> CredentialStore {
        CredentialStore { entries }
    }

    fn checkout(parent: &Path, name: &str, owner_repo: &str) -> std::path::PathBuf {
        let dir = parent.join(name);
        std::fs::create_dir_all(dir.join(".git")).unwrap();
        std::fs::write(
            dir.join(".git/config"),
            format!("[remote \"origin\"]\n    url = git@github.com:{owner_repo}.git\n"),
        )
        .unwrap();
        dir
    }

    #[test]
    fn store_change_line_names_outcome_key_and_count() {
        let e = entry("https://acme.aretta.ai", Some("acme/widgets"), "t");
        assert_eq!(
            store_change_line(UpsertOutcome::Added, &e, 1),
            "entry added: server https://acme.aretta.ai, repo acme/widgets — 1 entry on file."
        );
        assert_eq!(
            store_change_line(UpsertOutcome::Replaced { dropped: 2 }, &e, 3),
            "entry replaced (dropped 2 older entries for this repo): server https://acme.aretta.ai, repo acme/widgets — 3 entries on file."
        );
        let unscoped = entry("https://code.aretta.ai", None, "t");
        assert!(store_change_line(UpsertOutcome::Added, &unscoped, 1).contains("repo (unscoped)"));
    }

    #[test]
    fn login_verdict_matching_checkout_resolves() {
        let tmp = TempDir::new().unwrap();
        let dir = checkout(tmp.path(), "w", "acme/widgets");
        let saved = entry("https://code.aretta.ai", Some("acme/widgets"), "t1");
        let st = store(vec![
            entry("https://code.aretta.ai", Some("other/x"), "t0"),
            saved.clone(),
        ]);
        assert_eq!(
            login_verdict(&st, &saved, &dir),
            "this checkout (acme/widgets) resolves to this entry."
        );
    }

    #[test]
    fn login_verdict_mismatched_checkout_names_the_repo_and_both_remedies() {
        let tmp = TempDir::new().unwrap();
        let dir = checkout(tmp.path(), "fork", "alice/widgets");
        let saved = entry("https://code.aretta.ai", Some("acme/widgets"), "t1");
        let st = store(vec![
            entry("https://code.aretta.ai", Some("other/x"), "t0"),
            saved.clone(),
        ]);
        let v = login_verdict(&st, &saved, &dir);
        assert!(
            v.starts_with("this checkout (alice/widgets) will NOT resolve to this entry"),
            "{v}"
        );
        assert!(v.contains("run aristo from a acme/widgets checkout"), "{v}");
        assert!(
            v.contains("ARETTA_TOKEN=$(aristo auth token --repo acme/widgets)"),
            "{v}"
        );
    }

    #[test]
    fn login_verdict_outside_a_checkout_never_resolves() {
        // No single-entry fallback: even the only credential on file does
        // not apply in a directory that is not its checkout.
        let tmp = TempDir::new().unwrap();
        let plain = tmp.path().join("plain");
        std::fs::create_dir_all(&plain).unwrap();
        let saved = entry("https://code.aretta.ai", Some("acme/widgets"), "t1");
        let one = store(vec![saved.clone()]);
        let v = login_verdict(&one, &saved, &plain);
        assert!(v.contains("not a GitHub checkout (no .git/config"), "{v}");
        assert!(v.contains("nothing resolves here"), "{v}");
        assert!(v.contains("run aristo from a acme/widgets checkout"), "{v}");
        assert!(v.contains("ARETTA_TOKEN"), "{v}");
    }

    #[test]
    fn status_verdict_covers_all_four_branches() {
        let tmp = TempDir::new().unwrap();
        let acme = checkout(tmp.path(), "acme", "acme/widgets");
        let fork = checkout(tmp.path(), "fork", "alice/widgets");
        let plain = tmp.path().join("plain");
        std::fs::create_dir_all(&plain).unwrap();
        let a = entry("https://acme.aretta.ai", Some("acme/widgets"), "t1");
        let two = store(vec![
            entry("https://code.aretta.ai", Some("other/x"), "t0"),
            a.clone(),
        ]);
        assert_eq!(
            status_verdict(&two, &acme),
            "this checkout (acme/widgets) resolves to: server https://acme.aretta.ai, repo acme/widgets"
        );
        let v = status_verdict(&two, &fork);
        assert!(
            v.starts_with("this checkout (alice/widgets) resolves to: no stored credential"),
            "{v}"
        );
        assert!(
            v.contains(
                "`aristo auth login --server https://<org>.aretta.ai --repo alice/widgets` here"
            ),
            "{v}"
        );
        let v = status_verdict(&two, &plain);
        assert!(v.contains("not a GitHub checkout"), "{v}");
        assert!(v.contains("no stored credential (2 on file"), "{v}");
        // The only entry on file does not apply outside its checkout either.
        let one = store(vec![a]);
        let v = status_verdict(&one, &plain);
        assert!(v.contains("no stored credential (1 on file"), "{v}");
    }
}