Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311//! Clickable text for terminals that render OSC 8 hyperlinks: an `@handle`//! opens its Bluesky profile, a Tangled URL opens itself, in every place//! that already prints one. The escape is invisible everywhere it should//! be — a script reading stdout, output redirected to a file, a terminal//! that predates OSC 8 — because those all fail [`supported`] and get back//! the same plain text they always printed.
use std::io::IsTerminal;
/// Whether stdout will render an escape sequence at all — colour/// (`about.rs`, `repo.rs`, `review.rs`, `logs/oauth.rs`) and the OSC 8 links/// this module wraps text in both stand or fall on the same three facts, so/// there is exactly one function that knows them rather than each caller/// keeping its own copy:////// - `is_terminal`: a pipe, a redirect or a file gets plain text, so the/// output stays greppable and byte-stable./// - `no_color`: `NO_COLOR` set to anything non-empty/// (<https://no-color.org>) is an explicit opt out, terminal or not./// - `term_is_dumb`: `TERM=dumb` is a terminal *identifying itself* as/// unable to render any escape sequence, colour included, which is why/// this is not an OSC-8-only concern — git, GNU `ls --color=auto` and GNU/// `ls --hyperlink=auto` all treat it the same way, and a caller drawing/// colour on a terminal that just said it can't is the bug this rules out.////// A free function of three bools rather than a struct read from the/// environment so a test can hold all eight combinations still instead of/// mutating `TERM` or `NO_COLOR` on the process — which `cargo test`'s/// shared, threaded environment makes unsafe to do at all (see [`crate::docs::testing`]).pub fn escapes_wanted(is_terminal: bool, no_color: bool, term_is_dumb: bool) -> bool { is_terminal && !no_color && !term_is_dumb}
/// [`escapes_wanted`], read from this process's actual stdout and/// environment — what every caller outside a test wants.////// `--json` is a fourth veto, and it sits here rather than in the pure/// function above because it is not a fact about the terminal: escapes are/// off in that mode even on the most capable tty, since stdout is then a/// document rather than a display (see [`crate::term::jsonout`], rule 3). Keeping/// it at this one gate is what makes the rule hold for every colour and/// link decision in the tool at once, instead of each `--json` branch/// having to remember not to call a formatter.pub fn stdout_escapes_wanted() -> bool { !crate::term::jsonout::active() && escapes_wanted( std::io::stdout().is_terminal(), std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()), std::env::var_os("TERM").is_some_and(|t| t == "dumb"), )}
/// This is an allowlist's opposite on purpose — OSC 8 went from/// experimental to nearly ubiquitous (iTerm2, Kitty, WezTerm, Alacritty,/// Ghostty, Windows Terminal, VTE and everything built on it) over a few/// years, and an allowlist would need a new entry for every terminal that/// added support since. GNU `ls --hyperlink=auto` ships this same policy,/// which is why it is followed here instead of invented fresh.pub fn supported() -> bool { stdout_escapes_wanted()}
/// The OSC 8 escape itself, unconditional. Split from [`wrap`] so the byte/// sequence has one definition a test can check without faking a tty.fn osc8(url: &str, text: &str) -> String { format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")}
/// `text`, linking to `url` when [`supported`] — otherwise `text` unchanged,/// the fallback every caller already printed before this module existed.pub fn wrap(url: &str, text: &str) -> String { wrap_when(supported(), url, text)}
/// [`wrap`] with the decision passed in, for a caller that has to make it/// before the text exists: `auth login` writes a whole different sentence/// depending on whether the URL can be a link, so it asks [`supported`]/// once and hands the answer down to a function a test can hold still.pub fn wrap_when(clickable: bool, url: &str, text: &str) -> String { if clickable { osc8(url, text) } else { text.to_string() }}
/// `@handle`, linked to its Bluesky profile. The default destination for/// any atproto handle, not a Bluesky-specific one: bsky.app resolves a/// profile for every handle on the network, including accounts with no/// Bluesky-side presence beyond the identity itself.////// **The string is not ours.** It comes back from an index, or out of a DID/// document served by a host the subject of the DID chose, and it lands in/// the middle of an OSC 8 escape — so a handle carrying `\x1b\\` closes the/// sequence early and the rest of it is read by the terminal rather than/// drawn. [`is_a_handle`] is the guard, and a string that fails it prints as/// [`crate::term::text::one_line`] left it, with no link on it.////// Unlinked rather than linked-after-cleaning, which is the same call/// [`cut_account`] already makes one function down: a link is a promise that/// a profile is on the other end, and the cleaned form of a handle nobody/// could have is a handle nobody has either. Refusing to link is the honest/// half, and it is also the strict one — the URL is then only ever built out/// of a string that has already been checked.pub fn handle(raw: &str) -> String { if !is_a_handle(raw) { return format!("@{}", crate::term::text::one_line(raw)); } wrap( &format!("https://bsky.app/profile/{raw}"), &format!("@{raw}"), )}
/// Whether `raw` could be an atproto handle: the shape, not the identity.////// A handle is a domain name (<https://atproto.com/specs/handle>), so ASCII/// letters, digits, `-` and `.` are the whole alphabet — an internationalised/// one reaches the wire as punycode, which is in that set. Deliberately not/// [`crate::lexicon::identity`]'s validator, which asks jacquard and refuses/// the reserved TLDs: that answers "is this an account somebody could log in/// as", and the question here is only "can this be pasted into a URL and an/// escape sequence without changing what either one means". Borrowing the/// stricter one would unlink real accounts over a rule about resolvability,/// on a display path, and drag `lexicon/` into `term/` to do it.fn is_a_handle(raw: &str) -> bool { !raw.is_empty() && raw .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')}
/// How to name an account to a person: `@handle` linked to its profile, and/// the DID only when no handle is known.////// **The shape this replaces was written out six times** — `handle.map(|h|/// format!("@{h}")).unwrap_or_else(|| did.clone())` — in the comment thread,/// the key listing, both issue readers, the pull labels and `repo/// configure`. Six copies of one rule is six chances to differ, and they/// already had: none of them linked, and `key list` went on to print/// `{who} ({did})`, so an account with no handle came out as its DID twice.////// A DID is what a service is told and a handle is what a person is shown./// Where both are known the handle is the answer and the DID is noise; where/// only the DID is known it is all there is, and printing it once is the/// most that can be said.////// The DID is somebody else's string too, and it is the branch with no/// [`handle`] under it to check a shape. It is cleaned rather than validated:/// this module has no business deciding what a DID method's syntax allows,/// and dropping the characters a terminal *acts on* needs no such rule.pub fn account(handle: Option<&str>, did: &str) -> String { match handle { Some(h) => self::handle(h.trim_start_matches('@')), None => crate::term::text::one_line(did), }}
/// The same, for a label a column has already cut to width.////// A truncated handle is deliberately left unlinked: `@verylonghandl…` is/// not an account, and a link under it would open a profile page for/// somebody who does not exist. The cut has to happen first — [`ellipsize`]/// counts characters and would slice an escape sequence in half — so this is/// the second half of that pair, applied to what survived.////// [`ellipsize`]: crate::term::column::ellipsizepub fn cut_account(label: &str) -> String { match label.strip_prefix('@') { Some(h) if !h.ends_with('\u{2026}') => handle(h), _ => crate::term::text::one_line(label), }}
/// A URL, linked to itself. For the `tangled.org` lines atgc already prints/// as plain text to read or copy — this makes the same line also openable,/// without changing what it says.pub fn url(url: &str) -> String { wrap(url, url)}
#[cfg(test)]mod tests { use super::{account, cut_account, escapes_wanted, is_a_handle, osc8};
/// Escapes are off under a non-terminal stdout, which is what a test /// binary has, so both helpers reduce to the text they choose. That is /// the half worth pinning: *which* string is picked, not how it is /// painted. #[test] fn an_account_is_named_by_its_handle_and_falls_back_to_the_did() { assert_eq!(account(Some("alice.test"), "did:plc:abc"), "@alice.test"); // Already prefixed upstream, and prefixing it twice is a name // nobody has. assert_eq!(account(Some("@alice.test"), "did:plc:abc"), "@alice.test"); // The DID exactly once. `key list` used to print it beside a `who` // that had already fallen back to it. assert_eq!(account(None, "did:plc:abc"), "did:plc:abc"); }
/// A column cuts before this runs, so the truncated case is the one that /// matters: `@verylonghandl\u{2026}` names nobody, and linking it would /// promise a profile that does not exist. #[test] fn a_cut_handle_is_left_unlinked() { assert_eq!(cut_account("@alice.test"), "@alice.test"); assert_eq!( cut_account("@verylonghandl\u{2026}"), "@verylonghandl\u{2026}" ); assert_eq!(cut_account("did:plc:abc"), "did:plc:abc"); assert_eq!(cut_account(""), ""); }
/// The exact byte sequence, independent of whether this test's stdout /// happens to be a terminal — `wrap`'s gate is [`super::supported`], /// tested separately from the escape it produces. #[test] fn the_escape_wraps_text_in_the_link_and_closes_it() { assert_eq!( osc8("https://example.com", "text"), "\x1b]8;;https://example.com\x1b\\text\x1b]8;;\x1b\\" ); }
/// All eight combinations of the three facts, pinned directly rather /// than through `supported()` — this is the function every colour gate /// shares with the hyperlink one, so a regression here is a regression /// in five places at once. #[test] fn escapes_want_a_terminal_that_is_not_dumb_and_has_not_opted_out() { assert!(escapes_wanted(true, false, false)); assert!(!escapes_wanted(false, false, false), "not a terminal"); assert!(!escapes_wanted(true, true, false), "NO_COLOR set"); assert!(!escapes_wanted(true, false, true), "TERM=dumb"); assert!(!escapes_wanted(false, true, false)); assert!(!escapes_wanted(false, false, true)); assert!(!escapes_wanted(true, true, true)); assert!(!escapes_wanted(false, true, true)); }
/// cargo test captures stdout, so it is never a terminal here — this /// is the same fallback a pipe or a redirect gets in real use, and it /// is worth asserting explicitly rather than only relying on `about.rs` /// and `repo.rs` having established the pattern works. #[test] fn wrap_is_plain_text_off_a_terminal() { assert_eq!(super::wrap("https://example.com", "text"), "text"); }
#[test] fn a_handle_off_a_terminal_is_still_at_prefixed() { assert_eq!(super::handle("permadeath.com"), "@permadeath.com"); }
/// The handle goes inside the OSC 8 escape *and* inside its URL, so a /// string that is not shaped like a domain name is never allowed to reach /// either. Asserted on the guard directly: a test binary's stdout is not /// a terminal, so `handle` would flatten linked and unlinked into the /// same plain text and hide the difference this is about. #[test] fn only_a_domain_shaped_string_is_linked() { assert!(is_a_handle("permadeath.com")); assert!(is_a_handle("alice.test")); assert!(is_a_handle("xn--80ak6aa92e.com"), "punycode is ASCII"); assert!(!is_a_handle("")); assert!(!is_a_handle("evil.example\x1b\\"), "closes the escape"); assert!(!is_a_handle("a b.com"), "a space is not in a domain name"); assert!(!is_a_handle("évil.com"), "not on the wire in that form"); }
/// A handle that fails the shape check prints unlinked, and cleaned: /// `@` plus the text, with nothing a terminal would act on left in it. #[test] fn a_handle_that_is_not_one_prints_without_a_link() { // The escape that closes the OSC 8 sequence early, which is the // sequence the bug report named. assert_eq!( super::handle("evil.example\x1b\\x\x1b]8;;\x1b\\"), "@evil.example\\x]8;;\\" ); assert_eq!(super::handle("\x1b[2Jgone"), "@[2Jgone"); // And it is unlinked even where escapes are wanted, because the gate // is the shape of the string rather than the terminal. assert!(!super::handle("evil.example\x1b\\").contains('\x1b')); }
/// The DID branch has no shape to check, so it is cleaned instead — this /// is the one a comment thread reaches for every author the index could /// not resolve. #[test] fn a_did_that_is_all_a_thread_knows_is_still_cleaned() { assert_eq!( account(None, "did:plc:abc\x1b]8;;x\x1b\\"), "did:plc:abc]8;;x\\" ); assert_eq!( cut_account("did:plc:abc\r\nforged row"), "did:plc:abc forged row" ); }
#[test] fn a_url_off_a_terminal_is_unchanged() { assert_eq!( super::url("https://tangled.org/permadeath.com/atgc"), "https://tangled.org/permadeath.com/atgc" ); }}