use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::IsTerminal;
use std::path::Path;
use anyhow::{Context, Result};
use suno_core::select::{RecencySpec, SelectParams, select};
use suno_core::{
ArtifactToggles, ClerkAuth, Config, FlagOverrides, LineageContext, NamingConfig, OwnerGate,
PlaylistState, ResolveOpts, SourceMode, SunoClient, adopt_decision, adoption_enumerated,
album_desired, build_desired, build_modes_by_id, build_scoped_playlist_desired, clip_stems,
deletion_allowed, library_authoritative, narrows_downloads, owner_gate, resolve_roots,
source_statuses, union_clips,
};
use crate::cli::account;
use crate::cli::areas;
use crate::cli::args::{GlobalArgs, SyncArgs};
use crate::cli::commands::version;
use crate::cli::config_load;
use crate::cli::desired::{
Confirm, ExitCode, ResolvedSelection, confirm_decision, is_narrowed, mass_delete_abort,
resolve_selection, worse,
};
use crate::cli::execute;
use crate::cli::failure;
use crate::cli::identity::{Identity, IdentityContext, IdentityOutcome};
use crate::cli::last_run;
use crate::cli::logs;
use crate::cli::output;
use crate::cli::prompt;
use crate::cli::stems;
use crate::cli::synced_lyrics;
use crate::cli::task_output;
use crate::cli::task_output::eprint_t;
use crate::cli::token;
use crate::cli::wallclock;
use crate::clock::TokioClock;
use crate::http::ReqwestHttp;
const ACCOUNT_CONCURRENCY: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
Sync,
Copy,
Check,
}
impl Verb {
fn mode(self) -> SourceMode {
match self {
Verb::Sync | Verb::Check => SourceMode::Mirror,
Verb::Copy => SourceMode::Copy,
}
}
fn summary_label(self) -> &'static str {
match self {
Verb::Sync => "Sync",
Verb::Copy => "Copy",
Verb::Check => "Check",
}
}
fn progress_word(self) -> &'static str {
match self {
Verb::Sync => "sync",
Verb::Copy => "copy",
Verb::Check => "check",
}
}
}
pub async fn run_sync(global: &GlobalArgs, args: &SyncArgs) -> Result<ExitCode> {
run(Verb::Sync, global, args, false).await
}
pub async fn run_copy(global: &GlobalArgs, args: &SyncArgs) -> Result<ExitCode> {
run(Verb::Copy, global, args, false).await
}
pub async fn run_check(global: &GlobalArgs, args: &SyncArgs, exit_code: bool) -> Result<ExitCode> {
run(Verb::Check, global, args, exit_code).await
}
async fn run(
verb: Verb,
global: &GlobalArgs,
args: &SyncArgs,
exit_code: bool,
) -> Result<ExitCode> {
let env: HashMap<String, String> = std::env::vars().collect();
let token_available = token::token_available(global, &env);
let config = match config_load::load_config(global.config.as_deref())? {
config_load::ConfigState::Loaded(cfg) => Some(cfg),
config_load::ConfigState::Absent => None,
config_load::ConfigState::Error(message) => {
eprintln!("error: {message}");
return Ok(ExitCode::Config);
}
};
let sel = account::Selection {
all: global.all,
account: global.account.as_deref(),
dest: args.dest.as_deref(),
token_available,
};
let targets = match account::plan_targets(config.as_ref(), &sel) {
Ok(targets) => targets,
Err(message) => {
eprintln!("error: {message}");
return Ok(ExitCode::Config);
}
};
let mut worst = ExitCode::Ok;
if targets.len() <= 1 {
let flags = config_load::flag_overrides(global, args);
for target in targets {
let code = run_one(
verb,
global,
args,
&target,
config.as_ref(),
&flags,
&env,
exit_code,
)
.await?;
worst = worse(worst, code);
if code == ExitCode::Interrupted || code == ExitCode::DiskFull {
break;
}
}
} else {
use std::sync::Arc;
let global = Arc::new(global.clone());
let args = Arc::new(args.clone());
let config = Arc::new(config);
let env = Arc::new(env);
let sem = Arc::new(tokio::sync::Semaphore::new(ACCOUNT_CONCURRENCY));
let mut handles = Vec::new();
for target in targets {
let g = Arc::clone(&global);
let a = Arc::clone(&args);
let c = Arc::clone(&config);
let e = Arc::clone(&env);
let permit = Arc::clone(&sem)
.acquire_owned()
.await
.expect("semaphore closed");
handles.push(std::thread::spawn(move || {
let _permit = permit;
task_output::capture_task_stderr();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
let flags = config_load::flag_overrides(&g, &a);
let result = rt.block_on(run_one(
verb,
&g,
&a,
&target,
(*c).as_ref(),
&flags,
&e,
exit_code,
));
let lines = task_output::flush_task_stderr();
result.map(|code| (code, lines))
}));
}
for handle in handles {
let (code, lines) = handle
.join()
.map_err(|_| anyhow::anyhow!("account thread panicked"))??;
for line in lines {
eprintln!("{line}");
}
worst = worse(worst, code);
}
}
Ok(worst)
}
#[allow(clippy::too_many_arguments)]
async fn run_one(
verb: Verb,
global: &GlobalArgs,
args: &SyncArgs,
target: &account::TargetSpec,
config: Option<&Config>,
flags: &FlagOverrides,
env: &HashMap<String, String>,
exit_code: bool,
) -> Result<ExitCode> {
let verbosity = global.verbosity();
if args.allow_account_change && (global.dry_run || verb == Verb::Check) {
eprint_t!(
"error: --allow-account-change only applies to an executing sync or copy, not check or --dry-run."
);
return Ok(ExitCode::Usage);
}
let Preflight {
settings,
http,
client,
mut store,
user_id,
account,
} = match preflight(target, config, flags, env, verbosity).await? {
Ok(pre) => pre,
Err(code) => return Ok(code),
};
let dest = &target.dest;
let mut identity = Identity::default();
let identity_ctx = IdentityContext {
configured_id: settings.account_id.as_deref(),
user_id: &user_id,
account: &account,
dest,
allow_account_change: args.allow_account_change,
verbosity,
};
let gate = owner_gate(
store.owner(),
settings.account_id.as_deref(),
&user_id,
args.allow_account_change,
);
match identity.apply_owner_gate(&mut store, gate, &identity_ctx) {
IdentityOutcome::Abort { code, message } => {
eprint_t!("{message}");
return Ok(code);
}
IdentityOutcome::Continue { notice } => {
if let Some(notice) = notice {
eprint_t!("{notice}");
}
}
}
let ctx = RunCtx {
verb,
global,
args,
settings: &settings,
client: &client,
http: &http,
dest,
account: &account,
verbosity,
exit_code,
};
let force_copy_initial = verb == Verb::Copy || identity.force_additive();
let selection = resolve_selection(
verb.mode(),
args.mode.map(SourceMode::from),
args.liked,
&args.playlist,
settings.areas.as_ref(),
force_copy_initial,
);
let areas = match areas::enumerate_areas(
&selection,
&client,
&http,
&target.label,
args,
verbosity,
settings.concurrency,
)
.await
{
Ok(areas) => areas,
Err(code) => return Ok(code),
};
let clips = union_clips(&areas);
if clips.is_empty() && selection.library.is_none() {
if verbosity >= -1 {
eprint_t!("notice: nothing to do; the requested scope holds no downloadable clips.");
}
return Ok(ExitCode::Ok);
}
let archived_parents = store.archived_parents();
let resolution = match resolve_roots(
&clips,
&archived_parents,
&client,
&http,
ResolveOpts {
concurrency: settings.concurrency,
..ResolveOpts::default()
},
)
.await
{
Ok(resolution) => Some(resolution),
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: lineage resolution failed ({err}); using the last-known-good graph"
);
}
None
}
};
let graph_changed = resolution.is_some();
if let Some(resolution) = &resolution {
store.update(&clips, resolution, &wallclock::now_rfc3339());
}
let enumerated = adoption_enumerated(&areas, force_copy_initial);
if gate == OwnerGate::FirstUse {
let owned = logs::load_manifest(dest)?;
let owned_ids: BTreeSet<&str> = owned.entries.keys().map(String::as_str).collect();
let listed_ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
let decision = adopt_decision(
&listed_ids,
&owned_ids,
enumerated,
args.allow_account_change,
);
if let IdentityOutcome::Abort { code, message } =
identity.apply_adopt_decision(&mut store, decision, &identity_ctx)
{
eprint_t!("{message}");
return Ok(code);
}
}
let force_copy = verb == Verb::Copy || identity.force_additive();
let mut assembled = match assemble(
&ctx,
&areas,
&clips,
&store,
&selection,
force_copy,
graph_changed,
)
.await
{
Ok(assembled) => assembled,
Err(code) => return Ok(code),
};
if global.dry_run || verb == Verb::Check {
dry_run_report(&ctx, &mut assembled, &store).await
} else {
execute_run(&ctx, assembled, &mut store, &identity).await
}
}
struct RunCtx<'a> {
verb: Verb,
global: &'a GlobalArgs,
args: &'a SyncArgs,
settings: &'a suno_core::EffectiveSettings,
client: &'a SunoClient<TokioClock>,
http: &'a ReqwestHttp,
dest: &'a Path,
account: &'a str,
verbosity: i8,
exit_code: bool,
}
struct Preflight {
settings: suno_core::EffectiveSettings,
http: ReqwestHttp,
client: SunoClient<TokioClock>,
store: suno_core::LineageStore,
user_id: String,
account: String,
}
struct Assembled {
desired: Vec<suno_core::Desired>,
albums_desired: Vec<suno_core::AlbumDesired>,
playlist_desired: Vec<suno_core::PlaylistDesired>,
stored_playlists: BTreeMap<String, PlaylistState>,
sources: Vec<suno_core::SourceStatus>,
library_authoritative: bool,
playlists_enumerated: bool,
graph_changed: bool,
}
async fn preflight(
target: &account::TargetSpec,
config: Option<&Config>,
flags: &FlagOverrides,
env: &HashMap<String, String>,
verbosity: i8,
) -> Result<std::result::Result<Preflight, ExitCode>> {
let settings = {
let resolved = if target.implicit {
account::synthetic_config().resolve("default", None, env, flags)
} else {
config
.expect("non-implicit target has config")
.resolve(&target.label, None, env, flags)
};
match resolved {
Ok(settings) => settings,
Err(err) => {
eprint_t!("error: {err}");
return Ok(Err(ExitCode::Config));
}
}
};
let token = match token::resolve_token(&target.label, &settings).await {
Ok(Some(token)) => token,
Ok(None) => {
eprint_t!(
"error: no token for account '{}'; pass --token, set SUNO_TOKEN or SUNO_TOKEN_COMMAND, or set token/token_command in config",
target.label
);
return Ok(Err(ExitCode::Config));
}
Err(err) => {
eprint_t!("error: {err}");
return Ok(Err(ExitCode::Config));
}
};
if settings.requires_ffmpeg() && version::ffmpeg_version().is_none() {
eprint_t!(
"error: ffmpeg is required for {} output{} but was not found on PATH; \
install ffmpeg or switch to mp3 format",
settings.format,
if settings.animated_covers && !settings.raw_animated_cover {
" and animated WebP covers"
} else if settings.animated_covers {
" and animated covers"
} else {
""
}
);
return Ok(Err(ExitCode::Config));
}
let http = ReqwestHttp::new().context("failed to build the HTTP client")?;
let dest = &target.dest;
let auth = ClerkAuth::new(&token);
if let Err(err) = auth.authenticate(&http).await {
return Ok(Err(failure::report_auth_failure(&target.label, &err)));
}
let account = auth.display_name().to_owned();
crate::cli::expiry::warn_token_expiry(&target.label, &auth, verbosity);
let Some(user_id) = auth.user_id() else {
eprint_t!(
"error: could not determine the authenticated account for '{}'. Refusing to run to protect the library.",
target.label
);
return Ok(Err(ExitCode::Auth));
};
let mut store = logs::load_graph(dest)?;
store.refresh_eligible_roots();
store.set_album_overrides(settings.album_overrides.clone());
let client = SunoClient::new(auth, TokioClock);
Ok(Ok(Preflight {
settings,
http,
client,
store,
user_id,
account,
}))
}
async fn assemble(
ctx: &RunCtx<'_>,
areas: &[suno_core::AreaListing],
clips: &[suno_core::Clip],
store: &suno_core::LineageStore,
selection: &ResolvedSelection,
force_copy: bool,
graph_changed: bool,
) -> std::result::Result<Assembled, ExitCode> {
let settings = ctx.settings;
let args = ctx.args;
let verbosity = ctx.verbosity;
let sources = source_statuses(areas, force_copy);
let can_delete = deletion_allowed(&sources);
let library_authoritative = library_authoritative(areas, force_copy);
let colliding_albums = store.colliding_root_titles();
let modes_by_id = build_modes_by_id(areas, force_copy);
let since = match args.since.as_deref().map(RecencySpec::parse).transpose() {
Ok(since) => since,
Err(message) => {
eprint_t!("error: {message}");
return Err(ExitCode::Config);
}
};
let truncate = narrows_downloads(can_delete, library_authoritative);
if is_narrowed(args.limit, args.since.as_deref()) && !truncate && verbosity >= -1 {
let deletes = if can_delete {
"; the library is still mirrored and deletions apply"
} else {
""
};
eprint_t!(
"note: --limit/--since do not narrow this run (an authoritative full library is listed){deletes}"
);
}
let params = SelectParams {
limit: if truncate { args.limit } else { None },
since: if truncate { since } else { None },
min_newest: settings.min_newest as usize,
now: wallclock::now_secs(),
last_run: last_run::read_last_run(ctx.dest),
};
let selected = select(clips, ¶ms);
let contexts: HashMap<String, LineageContext> = selected
.iter()
.map(|clip| (clip.id.clone(), store.context_for(clip)))
.collect();
let mut desired = build_desired(
&selected,
settings.format,
&modes_by_id,
&contexts,
&colliding_albums,
ArtifactToggles {
animated_covers: settings.animated_covers,
details: settings.details_sidecar,
lyrics: settings.lyrics_sidecar,
lrc: settings.lrc_sidecar,
video: settings.video_mp4,
webp: settings.animated_cover_webp,
},
&NamingConfig {
template: settings.naming_template.clone(),
character_set: settings.character_set,
..NamingConfig::default()
},
);
let stems_by_id = stems::list_existing_stems(
settings.download_stems,
&selected,
ctx.client,
ctx.http,
settings.concurrency,
)
.await;
if settings.download_stems {
for d in &mut desired {
let base = stems::strip_format_ext(&d.path, settings.format);
d.stems = stems_by_id
.get(&d.clip.id)
.map(|stems| clip_stems(base, stems, settings.stem_format, settings.character_set));
}
}
let albums_desired = if library_authoritative {
album_desired(
&desired,
settings.animated_covers,
settings.raw_animated_cover,
settings.animated_cover_webp,
)
} else {
Vec::new()
};
let mut protected_playlists: BTreeSet<String> = BTreeSet::new();
let (playlist_desired, playlists_enumerated) =
if selection.is_plain_library() && library_authoritative {
areas::fetch_playlist_desired(
ctx.client,
ctx.http,
&desired,
&mut protected_playlists,
verbosity,
settings.concurrency,
)
.await
} else {
build_scoped_playlist_desired(
areas,
&desired,
store,
&mut protected_playlists,
force_copy,
!truncate,
)
};
let stored_playlists: BTreeMap<String, PlaylistState> = store
.playlists
.iter()
.filter(|(id, _)| !protected_playlists.contains(id.as_str()))
.map(|(id, state)| (id.clone(), state.clone()))
.collect();
Ok(Assembled {
desired,
albums_desired,
playlist_desired,
stored_playlists,
sources,
library_authoritative,
playlists_enumerated,
graph_changed,
})
}
async fn dry_run_report(
ctx: &RunCtx<'_>,
assembled: &mut Assembled,
store: &suno_core::LineageStore,
) -> Result<ExitCode> {
let manifest = logs::load_manifest(ctx.dest)?;
suno_core::preview_synced_lrc(
&mut assembled.desired,
&manifest,
wallclock::now_secs(),
ctx.settings.lrc_sidecar,
);
let plan = execute::reconcile_run(&execute::ReconcileInputs {
manifest: &manifest,
dest: ctx.dest,
desired: &assembled.desired,
albums_desired: &assembled.albums_desired,
albums: &store.albums,
playlist_desired: &assembled.playlist_desired,
playlists: &assembled.stored_playlists,
sources: &assembled.sources,
library_authoritative: assembled.library_authoritative,
playlists_enumerated: assembled.playlists_enumerated,
})
.await;
if ctx.verbosity >= 1 {
let no_failures = HashSet::new();
for line in output::action_lines(&plan, &no_failures, ctx.verbosity) {
eprint_t!("{line}");
}
}
if ctx.verbosity >= -1 {
eprint_t!("{}", output::dry_summary(ctx.account, &plan));
let orphans = suno_core::untracked_audio(&manifest, &execute::walk_audio_files(ctx.dest));
if !orphans.is_empty() {
eprint_t!("{}", output::orphan_report(&orphans));
}
}
if ctx.verb == Verb::Check && ctx.exit_code && prompt::plan_has_changes(&plan) {
return Ok(ExitCode::General);
}
Ok(ExitCode::Ok)
}
async fn execute_run(
ctx: &RunCtx<'_>,
mut assembled: Assembled,
store: &mut suno_core::LineageStore,
identity: &Identity,
) -> Result<ExitCode> {
let dest = ctx.dest;
let settings = ctx.settings;
let verbosity = ctx.verbosity;
std::fs::create_dir_all(dest)
.with_context(|| format!("could not create {}", dest.display()))?;
let _lock = logs::acquire_lock(dest)?;
let manifest = logs::load_manifest(dest)?;
let (synced, pending_checks) = synced_lyrics::resolve_synced_lyrics(
&mut assembled.desired,
&manifest,
ctx.client,
ctx.http,
settings.lrc_sidecar,
verbosity,
settings.concurrency,
)
.await;
let plan = execute::reconcile_run(&execute::ReconcileInputs {
manifest: &manifest,
dest,
desired: &assembled.desired,
albums_desired: &assembled.albums_desired,
albums: &store.albums,
playlist_desired: &assembled.playlist_desired,
playlists: &assembled.stored_playlists,
sources: &assembled.sources,
library_authoritative: assembled.library_authoritative,
playlists_enumerated: assembled.playlists_enumerated,
})
.await;
if assembled.graph_changed || identity.owner_dirty() {
logs::save_graph(dest, store)?;
}
if let Some(pin) = identity.pending_pin() {
if verbosity >= -1 {
eprint_t!("{}", pin.notice);
}
if let Some(owner) = store.owner() {
logs::append_owner_pin(dest, pin.action, &owner.user_id, &owner.display_name)?;
}
}
let is_sync = ctx.verb == Verb::Sync && !identity.force_additive();
let delete_count = plan.deletes() + plan.artifact_deletes() + plan.stem_deletes();
if is_sync
&& mass_delete_abort(
assembled.desired.len(),
manifest.len(),
delete_count,
settings.min_newest,
ctx.args.min_newest == Some(0),
ctx.global.yes,
)
{
eprint_t!(
"error: sync aborted -- deletion safety rule triggered\n\nThe listing yielded {} clip(s), which would delete {} of {} local file(s).\nThis is almost certainly a listing error. No files were deleted.\n\nIf you intended to delete everything, pass --min-newest 0 --yes to confirm.",
assembled.desired.len(),
delete_count,
manifest.len()
);
return Ok(ExitCode::Safety);
}
match confirm_decision(
is_sync,
delete_count,
ctx.global.yes,
std::io::stdin().is_terminal(),
) {
Confirm::Proceed => {}
Confirm::Prompt => {
if !prompt::prompt_delete(&plan, verbosity)? {
eprint_t!("Aborted; no changes made.");
return Ok(ExitCode::Ok);
}
}
Confirm::RefuseNonInteractive => {
eprint_t!(
"error: sync would delete {} file(s) but stdin is not a TTY and --yes was not passed\n Pass --yes to confirm, or use 'copy' to skip deletions.",
delete_count
);
return Ok(ExitCode::Safety);
}
}
if verbosity == 0 {
eprint_t!(
"{}",
output::progress_start(ctx.verb.progress_word(), ctx.account, &plan)
);
}
execute::execute_plan(
ctx.verb.summary_label(),
plan,
&assembled.desired,
manifest,
synced,
pending_checks,
store,
ctx.client,
ctx.http,
dest,
settings,
ctx.account,
verbosity,
assembled.library_authoritative,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use suno_core::{AreaKind, AreaListing, Clip, area_enumerated, area_mode};
#[test]
fn worse_prefers_higher_code() {
assert_eq!(worse(ExitCode::Ok, ExitCode::Partial), ExitCode::Partial);
assert_eq!(worse(ExitCode::Safety, ExitCode::Auth), ExitCode::Safety);
assert_eq!(worse(ExitCode::Ok, ExitCode::Ok), ExitCode::Ok);
}
#[test]
fn verb_modes_and_labels() {
assert_eq!(Verb::Sync.mode(), SourceMode::Mirror);
assert_eq!(Verb::Check.mode(), SourceMode::Mirror);
assert_eq!(Verb::Copy.mode(), SourceMode::Copy);
assert_eq!(Verb::Copy.summary_label(), "Copy");
}
#[tokio::test]
async fn allow_account_change_is_rejected_on_check_before_any_network() {
let global = GlobalArgs::default();
let args = SyncArgs {
allow_account_change: true,
..Default::default()
};
let target = account::TargetSpec {
label: "alice".to_owned(),
dest: PathBuf::from("/nonexistent-check-guard"),
implicit: false,
};
let flags = FlagOverrides::default();
let env = HashMap::new();
let code = run_one(
Verb::Check,
&global,
&args,
&target,
None,
&flags,
&env,
false,
)
.await
.unwrap();
assert_eq!(code, ExitCode::Usage);
}
#[tokio::test]
async fn allow_account_change_is_rejected_on_dry_run() {
let global = GlobalArgs {
dry_run: true,
..Default::default()
};
let args = SyncArgs {
allow_account_change: true,
..Default::default()
};
let target = account::TargetSpec {
label: "alice".to_owned(),
dest: PathBuf::from("/nonexistent-dryrun-guard"),
implicit: false,
};
let flags = FlagOverrides::default();
let env = HashMap::new();
let code = run_one(
Verb::Sync,
&global,
&args,
&target,
None,
&flags,
&env,
false,
)
.await
.unwrap();
assert_eq!(code, ExitCode::Usage);
}
fn tclip(id: &str) -> Clip {
Clip {
id: id.to_owned(),
title: "Song".to_owned(),
handle: "alice".to_owned(),
..Default::default()
}
}
fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
AreaListing::listed(
kind,
mode,
ids.iter().map(|id| tclip(id)).collect(),
authoritative,
false,
false,
)
}
#[test]
fn mirror_playlist_protects_library_exclusive_files() {
use suno_core::{LocalFile, Manifest, ManifestEntry, SourceStatus, reconcile};
let selection = resolve_selection(
SourceMode::Mirror,
Some(SourceMode::Mirror),
false,
&["holiday".to_owned()],
None,
false,
);
assert!(selection.library.unwrap().protector);
let areas = vec![
area(
AreaKind::Library,
SourceMode::Copy,
&["lib-only", "shared"],
true,
),
area(
AreaKind::Playlist {
id: "holiday".into(),
name: "Holiday".into(),
},
SourceMode::Mirror,
&["shared", "pl-only"],
true,
),
];
let force_copy = false;
let sources: Vec<SourceStatus> = areas
.iter()
.map(|a| SourceStatus {
mode: area_mode(a, force_copy),
fully_enumerated: area_enumerated(a, force_copy),
})
.collect();
assert!(deletion_allowed(&sources), "armed and fully enumerated");
let modes = build_modes_by_id(&areas, force_copy);
assert_eq!(modes["lib-only"], vec![SourceMode::Copy]);
assert_eq!(modes["shared"], vec![SourceMode::Mirror, SourceMode::Copy]);
assert_eq!(modes["pl-only"], vec![SourceMode::Mirror]);
let union = union_clips(&areas);
let desired = build_desired(
&union.iter().collect::<Vec<_>>(),
suno_core::AudioFormat::Flac,
&modes,
&HashMap::new(),
&BTreeSet::new(),
ArtifactToggles::default(),
&suno_core::NamingConfig::default(),
);
let mut manifest = Manifest::new();
for id in ["lib-only", "shared", "pl-only", "gone-orphan"] {
manifest.insert(
id,
ManifestEntry {
path: format!("{id}.flac"),
format: suno_core::AudioFormat::Flac,
size: 100,
..Default::default()
},
);
}
let local: HashMap<String, LocalFile> = manifest
.iter()
.map(|(id, _)| {
(
id.clone(),
LocalFile {
exists: true,
size: 100,
},
)
})
.collect();
let plan = reconcile(&manifest, &desired, &local, &sources);
let deleted: Vec<&str> = plan
.actions
.iter()
.filter_map(|a| match a {
suno_core::Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
_ => None,
})
.collect();
assert_eq!(deleted, vec!["gone-orphan"]);
}
}