use std::path::Path;
use suno_core::{AdoptDecision, LineageStore, Owner, OwnerGate};
use crate::cli::desired::ExitCode;
use crate::cli::output::short_id;
pub struct PendingPin {
pub action: &'static str,
pub notice: String,
}
pub enum IdentityOutcome {
Continue { notice: Option<String> },
Abort { code: ExitCode, message: String },
}
pub struct IdentityContext<'a> {
pub configured_id: Option<&'a str>,
pub user_id: &'a str,
pub account: &'a str,
pub dest: &'a Path,
pub allow_account_change: bool,
pub verbosity: i8,
}
#[derive(Default)]
pub struct Identity {
force_additive: bool,
owner_dirty: bool,
pending_pin: Option<PendingPin>,
}
impl Identity {
pub fn force_additive(&self) -> bool {
self.force_additive
}
pub fn owner_dirty(&self) -> bool {
self.owner_dirty
}
pub fn pending_pin(&self) -> Option<&PendingPin> {
self.pending_pin.as_ref()
}
pub fn apply_owner_gate(
&mut self,
store: &mut LineageStore,
gate: OwnerGate,
ctx: &IdentityContext,
) -> IdentityOutcome {
self.force_additive = gate.is_additive();
match gate {
OwnerGate::AbortConfigMismatch => IdentityOutcome::Abort {
code: ExitCode::Safety,
message: format!(
"error: the configured account_id ({}) does not match the authenticated account (id {}). Refusing to run to protect the library.",
short_id(ctx.configured_id.unwrap_or_default()),
short_id(ctx.user_id)
),
},
OwnerGate::AbortMismatch => {
let pinned = store.owner().expect("mismatch implies a pinned owner");
IdentityOutcome::Abort {
code: ExitCode::Safety,
message: format!(
"error: this library belongs to {} (id {}) but the token authenticates as {} (id {}). Refusing to run to protect the library. Pass --allow-account-change to re-pin it to the authenticated account, or use a different destination.",
pinned.display_name,
short_id(&pinned.user_id),
ctx.account,
short_id(ctx.user_id)
),
}
}
OwnerGate::Repin => {
let previous = store
.owner()
.map(|owner| owner.display_name.clone())
.unwrap_or_default();
self.set_pin(
store,
ctx.user_id,
ctx.account,
"REPIN",
format!(
"notice: re-pinned this library from {} to {} (id {}); this run is additive (no deletions). Run 'sync' again to mirror.",
previous,
ctx.account,
short_id(ctx.user_id)
),
);
IdentityOutcome::Continue { notice: None }
}
OwnerGate::Proceed => {
if store.refresh_display_name(ctx.account) {
self.owner_dirty = true;
}
let notice = (ctx.allow_account_change && ctx.verbosity >= 0).then(|| {
format!(
"notice: --allow-account-change had no effect; this library already belongs to {} (id {}).",
ctx.account,
short_id(ctx.user_id)
)
});
IdentityOutcome::Continue { notice }
}
OwnerGate::FirstUse => IdentityOutcome::Continue { notice: None },
}
}
pub fn apply_adopt_decision(
&mut self,
store: &mut LineageStore,
decision: AdoptDecision,
ctx: &IdentityContext,
) -> IdentityOutcome {
self.force_additive = self.force_additive || decision.is_additive();
match decision {
AdoptDecision::PinFresh => {
self.set_pin(
store,
ctx.user_id,
ctx.account,
"PIN",
format!(
"notice: pinned this library to {} (id {}).",
ctx.account,
short_id(ctx.user_id)
),
);
IdentityOutcome::Continue { notice: None }
}
AdoptDecision::PinAdopt => {
self.set_pin(
store,
ctx.user_id,
ctx.account,
"ADOPT",
format!(
"notice: adopted this existing library for {} (id {}).",
ctx.account,
short_id(ctx.user_id)
),
);
IdentityOutcome::Continue { notice: None }
}
AdoptDecision::AdoptForced => {
self.set_pin(
store,
ctx.user_id,
ctx.account,
"ADOPT",
format!(
"notice: adopted this library for {} (id {}) despite no overlap; this run is additive (no deletions). Run 'sync' again to mirror.",
ctx.account,
short_id(ctx.user_id)
),
);
IdentityOutcome::Continue { notice: None }
}
AdoptDecision::Abort => IdentityOutcome::Abort {
code: ExitCode::Safety,
message: format!(
"error: none of the authenticated account's clips ({}, id {}) match this library at {}. Refusing to run in case the token authenticates as a different Suno account. Pass --allow-account-change to adopt it, or use a different destination.",
ctx.account,
short_id(ctx.user_id),
ctx.dest.display()
),
},
AdoptDecision::SkipPin => IdentityOutcome::Continue { notice: None },
}
}
fn set_pin(
&mut self,
store: &mut LineageStore,
user_id: &str,
account: &str,
action: &'static str,
notice: String,
) {
store.pin_owner(Owner {
user_id: user_id.to_owned(),
display_name: account.to_owned(),
});
self.owner_dirty = true;
self.pending_pin = Some(PendingPin { action, notice });
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn owner(id: &str, name: &str) -> Owner {
Owner {
user_id: id.to_owned(),
display_name: name.to_owned(),
}
}
fn ctx<'a>(user_id: &'a str, account: &'a str, dest: &'a Path) -> IdentityContext<'a> {
IdentityContext {
configured_id: None,
user_id,
account,
dest,
allow_account_change: false,
verbosity: 0,
}
}
fn notice(outcome: &IdentityOutcome) -> Option<&str> {
match outcome {
IdentityOutcome::Continue { notice } => notice.as_deref(),
IdentityOutcome::Abort { .. } => panic!("expected Continue, got Abort"),
}
}
fn abort(outcome: &IdentityOutcome) -> (ExitCode, &str) {
match outcome {
IdentityOutcome::Abort { code, message } => (*code, message.as_str()),
IdentityOutcome::Continue { .. } => panic!("expected Abort, got Continue"),
}
}
#[test]
fn config_mismatch_aborts_without_pinning() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let c = IdentityContext {
configured_id: Some("user_c"),
..ctx("user_a", "Alice", &dest)
};
let outcome = identity.apply_owner_gate(&mut store, OwnerGate::AbortConfigMismatch, &c);
let (code, message) = abort(&outcome);
assert_eq!(code, ExitCode::Safety);
assert!(message.contains("configured account_id"));
assert!(store.owner().is_none());
assert!(!identity.force_additive());
assert!(!identity.owner_dirty());
assert!(identity.pending_pin().is_none());
}
#[test]
fn owner_mismatch_aborts_and_leaves_the_pin_untouched() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
store.pin_owner(owner("user_a", "Alice"));
let mut identity = Identity::default();
let outcome = identity.apply_owner_gate(
&mut store,
OwnerGate::AbortMismatch,
&ctx("user_b", "Bob", &dest),
);
let (code, message) = abort(&outcome);
assert_eq!(code, ExitCode::Safety);
assert!(message.contains("this library belongs to Alice"));
assert_eq!(store.owner().unwrap().user_id, "user_a");
assert!(!identity.force_additive());
}
#[test]
fn repin_pins_the_new_owner_additively() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
store.pin_owner(owner("user_a", "Alice"));
let mut identity = Identity::default();
let outcome =
identity.apply_owner_gate(&mut store, OwnerGate::Repin, &ctx("user_b", "Bob", &dest));
assert!(notice(&outcome).is_none());
assert!(identity.force_additive(), "re-pin disarms deletion");
assert!(identity.owner_dirty());
let pin = identity.pending_pin().expect("a re-pin is queued");
assert_eq!(pin.action, "REPIN");
assert!(
pin.notice
.contains("re-pinned this library from Alice to Bob")
);
assert_eq!(store.owner().unwrap().user_id, "user_b");
}
#[test]
fn proceed_refreshes_a_changed_display_name() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
store.pin_owner(owner("user_a", "Alice"));
let mut identity = Identity::default();
let outcome = identity.apply_owner_gate(
&mut store,
OwnerGate::Proceed,
&ctx("user_a", "Alice Cooper", &dest),
);
assert!(notice(&outcome).is_none());
assert!(
identity.owner_dirty(),
"a changed name marks the store dirty"
);
assert!(!identity.force_additive());
assert!(identity.pending_pin().is_none());
assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
}
#[test]
fn proceed_notes_a_redundant_allow_account_change() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
store.pin_owner(owner("user_a", "Alice"));
let mut identity = Identity::default();
let c = IdentityContext {
allow_account_change: true,
..ctx("user_a", "Alice", &dest)
};
let outcome = identity.apply_owner_gate(&mut store, OwnerGate::Proceed, &c);
assert!(notice(&outcome).unwrap().contains("had no effect"));
}
#[test]
fn proceed_suppresses_the_note_when_quiet() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
store.pin_owner(owner("user_a", "Alice"));
let mut identity = Identity::default();
let c = IdentityContext {
allow_account_change: true,
verbosity: -1,
..ctx("user_a", "Alice", &dest)
};
let outcome = identity.apply_owner_gate(&mut store, OwnerGate::Proceed, &c);
assert!(notice(&outcome).is_none());
}
#[test]
fn first_use_defers_without_side_effects() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_owner_gate(
&mut store,
OwnerGate::FirstUse,
&ctx("user_a", "Alice", &dest),
);
assert!(notice(&outcome).is_none());
assert!(store.owner().is_none());
assert!(!identity.force_additive());
assert!(!identity.owner_dirty());
}
#[test]
fn adopt_pin_fresh_pins_a_new_library() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_adopt_decision(
&mut store,
AdoptDecision::PinFresh,
&ctx("user_a", "Alice", &dest),
);
assert!(notice(&outcome).is_none());
let pin = identity.pending_pin().expect("a pin is queued");
assert_eq!(pin.action, "PIN");
assert!(pin.notice.contains("pinned this library to Alice"));
assert!(identity.owner_dirty());
assert!(!identity.force_additive());
assert_eq!(store.owner().unwrap().user_id, "user_a");
}
#[test]
fn adopt_existing_pins_without_arming_additive() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_adopt_decision(
&mut store,
AdoptDecision::PinAdopt,
&ctx("user_a", "Alice", &dest),
);
assert!(notice(&outcome).is_none());
let pin = identity.pending_pin().expect("a pin is queued");
assert_eq!(pin.action, "ADOPT");
assert!(pin.notice.contains("adopted this existing library"));
assert!(!identity.force_additive());
}
#[test]
fn adopt_forced_pins_additively() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_adopt_decision(
&mut store,
AdoptDecision::AdoptForced,
&ctx("user_a", "Alice", &dest),
);
assert!(notice(&outcome).is_none());
let pin = identity.pending_pin().expect("a pin is queued");
assert_eq!(pin.action, "ADOPT");
assert!(pin.notice.contains("despite no overlap"));
assert!(
identity.force_additive(),
"forced adoption disarms deletion"
);
}
#[test]
fn adopt_abort_refuses_and_names_the_destination() {
let dest = PathBuf::from("/some/library");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_adopt_decision(
&mut store,
AdoptDecision::Abort,
&ctx("user_a", "Alice", &dest),
);
let (code, message) = abort(&outcome);
assert_eq!(code, ExitCode::Safety);
assert!(message.contains("/some/library"));
assert!(store.owner().is_none());
}
#[test]
fn adopt_skip_leaves_the_library_unpinned() {
let dest = PathBuf::from("/lib");
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_adopt_decision(
&mut store,
AdoptDecision::SkipPin,
&ctx("user_a", "Alice", &dest),
);
assert!(notice(&outcome).is_none());
assert!(store.owner().is_none());
assert!(!identity.owner_dirty());
assert!(identity.pending_pin().is_none());
}
#[test]
fn messages_only_ever_show_the_short_id_prefix() {
let dest = PathBuf::from("/lib");
let full = "user_abcdefghijklmnop";
let mut store = LineageStore::new();
let mut identity = Identity::default();
let outcome = identity.apply_adopt_decision(
&mut store,
AdoptDecision::Abort,
&ctx(full, "Alice", &dest),
);
let (_, message) = abort(&outcome);
assert!(message.contains("user_abc"), "the 8-char prefix is shown");
assert!(!message.contains(full), "the full id is never printed");
}
}