1use crate::io::{
7 command_exists,
8 config::{FilterSet, ModelEntry},
9 files_all, home_directory, write_file, ApiResult, PathConversion, Source,
10};
11use crate::prelude::{canonicalize, create_dir_all, remove_file, rename, Path, PathBuf};
12use crate::schema::agent::ModelDetails;
13use crate::util::constants::app::DEFAULT_MODELS_DIRECTORY;
14use crate::util::{text_diff_changes_with_color, Label};
15use alloc::collections::{BTreeMap, BTreeSet};
16use alloc::string::String;
17use alloc::vec::Vec;
18use bon::Builder;
19use color_eyre::eyre::eyre;
20use core::fmt;
21use core::ops::ControlFlow;
22use owo_colors::OwoColorize;
23use serde::{Deserialize, Serialize};
24use serde_with::skip_serializing_none;
25use tracing::info;
26use validator::Validate;
27
28pub mod goose;
29pub mod llama_swap;
30pub mod opencode;
31pub mod vscode;
32
33#[derive(Debug)]
34enum SyncError {
35 Goose(color_eyre::Report),
36 LlamaSwap(color_eyre::Report),
37 OpenCode(color_eyre::Report),
38 VsCode(color_eyre::Report),
39}
40#[derive(Debug)]
41enum ShardError {
42 Ambiguous { model: String, paths: Vec<String> },
43 Canonicalize { model: String, why: color_eyre::Report },
44 Malformed(Shard),
45 EmptyCandidate,
46 EmptyGroup,
47 Incomplete { key: String, count: u64 },
48}
49#[skip_serializing_none]
74#[derive(Clone, Debug, Default, Deserialize, Serialize, Validate)]
75#[serde(rename_all = "camelCase")]
76pub struct Config {
77 #[validate(nested)]
79 pub goose: Option<goose::Config>,
80 #[validate(nested)]
82 pub llama_swap: Option<llama_swap::Config>,
83 #[validate(nested)]
85 pub opencode: Option<opencode::Config>,
86 #[validate(nested)]
88 pub vscode: Option<vscode::Config>,
89}
90#[derive(Builder, Clone, Copy, Debug, Default)]
92#[builder(start_fn = init)]
93pub struct Options<'a> {
94 #[builder(default)]
96 pub models: &'a [ModelDetails],
97 #[builder(default)]
99 pub entries: &'a [ModelEntry],
100 #[builder(default)]
102 pub opencode: bool,
103 #[builder(default)]
105 pub vscode: bool,
106 #[builder(default)]
108 pub goose: bool,
109 #[builder(default)]
111 pub llama_swap: bool,
112 #[builder(default)]
114 pub dry_run: bool,
115 #[builder(default)]
117 pub no_color: bool,
118 #[builder(default)]
120 pub prune: bool,
121 #[builder(default)]
123 pub force: bool,
124 #[builder(default)]
126 pub assume_models: bool,
127 pub models_dir: Option<&'a Path>,
129}
130#[derive(Clone, Debug)]
132pub struct ModelRequest {
133 id: String,
134 source: Source,
135 filter: Vec<String>,
136 ignore: Vec<String>,
137}
138#[derive(Clone, Debug)]
140pub struct ModelRequestOptions<'a> {
141 pub models_dir: &'a Path,
143 pub assume_models: bool,
145 pub fallbacks: Vec<String>,
147}
148#[derive(Clone, Debug)]
149pub(crate) struct RenderedOutput {
150 target: &'static str,
151 path: PathBuf,
152 before: String,
153 content: String,
154}
155pub(crate) trait SyncTarget: Clone + Default {
156 const COMMAND: &'static str;
157 fn merge(self, overrides: Self) -> Self;
158 fn merge_cli_overrides(self, overrides: Self) -> Self;
159 fn resolve_path(explicit: Option<&str>) -> ApiResult<PathBuf>;
160 fn render(&self, options: Options<'_>) -> ApiResult<RenderedOutput>;
161}
162#[derive(Clone, Debug, PartialEq, Eq)]
164pub(crate) struct Shard {
165 key: String,
166 index: Option<u64>,
167 count: Option<u64>,
168 malformed: bool,
169 path: PathBuf,
170}
171#[derive(Clone, Debug, Default, PartialEq, Eq)]
172struct Shards(Vec<Shard>);
173fn merge_target<T: SyncTarget>(config: Option<T>, overrides: Option<T>) -> Option<T> {
174 match (config, overrides) {
175 | (Some(config), Some(overrides)) => Some(config.merge(overrides)),
176 | (config, overrides) => overrides.or(config),
177 }
178}
179fn merge_target_cli<T: SyncTarget>(config: Option<T>, overrides: Option<T>) -> Option<T> {
180 match (config, overrides) {
181 | (Some(config), Some(overrides)) => Some(config.merge_cli_overrides(overrides)),
182 | (config, overrides) => overrides.or(config),
183 }
184}
185fn select_target<T: SyncTarget>(config: Option<&T>, requested: bool, explicit: bool, force: bool) -> Option<T> {
186 ((requested || !explicit) && (command_exists(T::COMMAND) || (requested && force))).then(|| config.cloned().unwrap_or_default())
187}
188impl Config {
189 pub fn merge(self, overrides: Self) -> Self {
191 let Self {
192 goose,
193 llama_swap,
194 opencode,
195 vscode,
196 } = self;
197 Self {
198 goose: merge_target(goose, overrides.goose),
199 llama_swap: merge_target(llama_swap, overrides.llama_swap),
200 opencode: merge_target(opencode, overrides.opencode),
201 vscode: merge_target(vscode, overrides.vscode),
202 }
203 }
204 pub fn merge_cli_overrides(self, overrides: Self) -> Self {
206 let Self {
207 goose,
208 llama_swap,
209 opencode,
210 vscode,
211 } = self;
212 Self {
213 goose: merge_target_cli(goose, overrides.goose),
214 llama_swap: merge_target_cli(llama_swap, overrides.llama_swap),
215 opencode: merge_target_cli(opencode, overrides.opencode),
216 vscode: merge_target_cli(vscode, overrides.vscode),
217 }
218 }
219 pub fn resolve_models_dir(&self, override_directory: Option<&Path>) -> ApiResult<PathBuf> {
221 override_directory
222 .map(PathBuf::from)
223 .or_else(|| {
224 self.llama_swap
225 .as_ref()
226 .and_then(|config| config.models_directory.as_ref())
227 .map(PathBuf::from)
228 })
229 .map_or_else(|| home_directory(DEFAULT_MODELS_DIRECTORY), Ok)
230 }
231 pub fn sync(&self, options: Options<'_>) -> ApiResult<()> {
233 let selected = self.selected(&options);
234 selected.models_for_sync(&options).and_then(|models| {
235 let options = Options { models: &models, ..options };
236 let model_ids = options.models.iter().filter_map(|model| model.id.as_ref()).cloned().collect::<Vec<_>>();
237 Validate::validate(&selected)
238 .map_err(|why| eyre!("Invalid synchronization configuration: {why}"))
239 .and_then(|()| {
240 selected.llama_swap.as_ref().map_or(Ok(()), |config| {
241 Validate::validate(&llama_swap::ModelValidation::from((config, model_ids.as_slice())))
242 .map_err(|why| eyre!("Invalid llama-swap model configuration: {why}"))
243 })
244 })
245 .and_then(|()| {
246 selected
247 .opencode
248 .as_ref()
249 .and_then(|config| config.default_model.as_ref())
250 .map_or(Ok(()), |default_model| match model_ids.iter().any(|model_id| model_id == default_model) {
251 | true => Ok(()),
252 | false => Err(eyre!("opencode.defaultModel references unknown model '{default_model}'")),
253 })
254 })
255 .and_then(|()| {
256 selected
257 .goose
258 .as_ref()
259 .and_then(|config| config.default_model.as_ref())
260 .map_or(Ok(()), |default_model| match model_ids.iter().any(|model_id| model_id == default_model) {
261 | true => Ok(()),
262 | false => Err(eyre!("goose.defaultModel references unknown model '{default_model}'")),
263 })
264 })
265 .and_then(|()| match (selected.is_empty(), options.models.is_empty()) {
266 | (true, _) => {
267 info!("{} No eligible synchronization targets detected — nothing to synchronize", Label::CAUTION);
268 Ok(())
269 }
270 | (_, true) => {
271 info!("{} No models resolved — nothing to synchronize", Label::CAUTION);
272 Ok(())
273 }
274 | _ => [
275 selected
276 .llama_swap
277 .as_ref()
278 .map(|config| SyncTarget::render(config, options).map_err(|why| eyre!(SyncError::LlamaSwap(why)))),
279 selected
280 .opencode
281 .as_ref()
282 .map(|config| SyncTarget::render(config, options).map_err(|why| eyre!(SyncError::OpenCode(why)))),
283 selected
284 .vscode
285 .as_ref()
286 .map(|config| SyncTarget::render(config, options).map_err(|why| eyre!(SyncError::VsCode(why)))),
287 selected
288 .goose
289 .as_ref()
290 .map(|config| SyncTarget::render(config, options).map_err(|why| eyre!(SyncError::Goose(why)))),
291 ]
292 .into_iter()
293 .flatten()
294 .collect::<ApiResult<Vec<_>>>()
295 .and_then(|outputs| match options.dry_run {
296 | true => {
297 outputs.iter().for_each(|output| {
298 let path = output.path.display().to_string();
299 match (output.before == output.content, options.no_color) {
300 | (true, true) => info!("=> {} No changes for {path}", Label::CAUTION),
301 | (true, false) => info!("=> {} No changes for {}", Label::CAUTION, path.cyan()),
302 | (false, _) => {
303 match options.no_color {
304 | true => println!("\n{path}"),
305 | false => println!("\n{}", path.cyan().bold()),
306 }
307 text_diff_changes_with_color(&output.before, &output.content, !options.no_color)
308 .iter()
309 .for_each(|(_, line)| print!("{line}"));
310 }
311 }
312 });
313 info!("=> {} Dry run complete — no files were modified", Label::pass());
314 Ok(Vec::new())
315 }
316 | false => Self::commit(&outputs),
317 })
318 .map(|paths| {
319 paths
320 .iter()
321 .for_each(|path| info!("=> {} Updated {}", Label::pass(), path.display().cyan()));
322 }),
323 })
324 })
325 }
326 fn models_for_sync(&self, options: &Options<'_>) -> ApiResult<Vec<ModelDetails>> {
327 match (self.llama_swap.is_none(), options.entries.is_empty(), options.models_dir) {
328 | (true, false, Some(models_dir)) => ModelEntry::requests(options.entries).map(|requests| {
329 options
330 .models
331 .iter()
332 .cloned()
333 .chain(
334 requests
335 .into_iter()
336 .filter(|request| !options.models.iter().any(|model| model.id.as_deref() == Some(request.id())))
337 .filter(|request| request.source_exists(models_dir))
338 .map(|request| ModelDetails::init().id(request.id().to_string()).name(request.id().to_string()).build()),
339 )
340 .collect()
341 }),
342 | _ => Ok(options.models.to_vec()),
343 }
344 }
345 fn selected(&self, options: &Options<'_>) -> Self {
346 let Options {
347 goose,
348 llama_swap,
349 opencode,
350 vscode,
351 force,
352 ..
353 } = options;
354 let explicit = *llama_swap || *opencode || *vscode || *goose;
355 Self {
356 goose: select_target(self.goose.as_ref(), *goose, explicit, *force),
357 llama_swap: select_target(self.llama_swap.as_ref(), *llama_swap, explicit, *force),
358 opencode: select_target(self.opencode.as_ref(), *opencode, explicit, *force),
359 vscode: select_target(self.vscode.as_ref(), *vscode, explicit, *force),
360 }
361 }
362 fn is_empty(&self) -> bool {
363 let Self {
364 goose,
365 llama_swap,
366 opencode,
367 vscode,
368 } = self;
369 goose.is_none() && llama_swap.is_none() && opencode.is_none() && vscode.is_none()
370 }
371 fn commit(outputs: &[RenderedOutput]) -> ApiResult<Vec<PathBuf>> {
372 let changed = outputs.iter().filter(|output| output.before != output.content).collect::<Vec<_>>();
373 let paths = changed.iter().map(|output| output.path.clone()).collect::<Vec<_>>();
374 let unique_paths = paths.iter().collect::<BTreeSet<_>>();
375 match unique_paths.len() == paths.len() {
376 | true => Ok(()),
377 | false => Err(eyre!("Selected synchronization targets resolve to the same output path")),
378 }
379 .and_then(|()| {
380 changed
381 .iter()
382 .try_for_each(|output| match (output.temp_path().exists(), output.backup_path().exists()) {
383 | (true, _) => Err(eyre!("Temporary sync path already exists — {}", output.temp_path().display())),
384 | (_, true) => Err(eyre!("Backup sync path already exists — {}", output.backup_path().display())),
385 | _ => Ok(()),
386 })
387 })
388 .and_then(|()| {
389 changed
390 .iter()
391 .try_for_each(|output| {
392 output
393 .path
394 .parent()
395 .map_or(Ok(()), create_dir_all)
396 .map_err(|why| eyre!("Failed to create parent directory for {} config — {why}", output.target))
397 .and_then(|()| write_file(output.temp_path(), output.content.clone()))
398 .map_err(|why| eyre!("Failed to stage {} config — {why}", output.target))
399 })
400 .inspect_err(|_why| {
401 changed.iter().for_each(|output| output.cleanup_temp());
402 })
403 })
404 .and_then(|()| {
405 changed
406 .iter()
407 .filter(|output| output.path.is_file())
408 .try_fold(Vec::new(), |mut backed_up, output| match rename(&output.path, output.backup_path()) {
409 | Ok(()) => {
410 backed_up.push(*output);
411 Ok(backed_up)
412 }
413 | Err(why) => {
414 backed_up.iter().for_each(|output: &&RenderedOutput| output.restore_backup());
415 changed.iter().for_each(|output| output.cleanup_temp());
416 Err(eyre!("Failed to prepare coordinated configuration update: {why}"))
417 }
418 })
419 .and_then(|backed_up| {
420 changed
421 .iter()
422 .try_fold(Vec::new(), |mut committed, output| match rename(output.temp_path(), &output.path) {
423 | Ok(()) => {
424 committed.push(*output);
425 Ok(committed)
426 }
427 | Err(why) => {
428 committed.iter().for_each(|output: &&RenderedOutput| output.cleanup_target());
429 backed_up.iter().for_each(|output| output.restore_backup());
430 changed.iter().for_each(|output| output.cleanup_temp());
431 Err(eyre!("Failed to commit coordinated configuration update: {why}"))
432 }
433 })
434 .map(|_| {
435 backed_up.iter().for_each(|output| {
436 remove_file(output.backup_path()).ok();
437 });
438 })
439 })
440 })
441 .map(|()| paths)
442 }
443}
444impl RenderedOutput {
445 fn backup_path(&self) -> PathBuf {
446 PathBuf::from(format!("{}.acorn-sync-backup", self.path.display()))
447 }
448 fn cleanup_target(&self) {
449 remove_file(&self.path).ok();
450 }
451 fn cleanup_temp(&self) {
452 remove_file(self.temp_path()).ok();
453 }
454 fn restore_backup(&self) {
455 self.cleanup_target();
456 rename(self.backup_path(), &self.path).ok();
457 }
458 fn temp_path(&self) -> PathBuf {
459 PathBuf::from(format!("{}.acorn-sync-tmp", self.path.display()))
460 }
461}
462impl ModelRequest {
463 pub fn assume(&self, models_dir: &Path) -> ModelDetails {
465 let path = match &self.source {
466 | Source::Local { path, .. } => path.clone(),
467 | _ => models_dir.join(&self.id),
468 };
469 ModelDetails::init()
470 .id(self.id.clone())
471 .name(self.id.clone())
472 .path(path.display().to_string())
473 .build()
474 }
475 fn details(&self, path: PathBuf) -> ApiResult<ModelDetails> {
476 canonicalize(&path)
477 .map_err(|why| {
478 eyre!(ShardError::Canonicalize {
479 model: self.id.clone(),
480 why: why.into(),
481 })
482 })
483 .map(|path| ModelDetails {
484 id: Some(self.id.clone()),
485 name: Some(self.id.clone()),
486 path: Some(path.cross_platform_display()),
487 ..Default::default()
488 })
489 }
490 pub fn id(&self) -> &str {
492 &self.id
493 }
494 fn source_exists(&self, models_dir: &Path) -> bool {
495 match &self.source {
496 | Source::Local { path, .. } => path.exists(),
497 | _ => models_dir.join(&self.id).is_dir(),
498 }
499 }
500 fn is_gguf(path: &Path) -> bool {
501 path.extension()
502 .and_then(|extension| extension.to_str())
503 .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf"))
504 }
505 fn new(id: String, source: Source, filter: Vec<String>, ignore: Vec<String>) -> ApiResult<Self> {
506 match id.trim() {
507 | "" => Err(eyre!("Configured model ID cannot be empty")),
508 | id => Ok(Self {
509 id: id.to_string(),
510 source,
511 filter,
512 ignore,
513 }),
514 }
515 }
516 pub fn resolve(&self, options: &ModelRequestOptions<'_>) -> ApiResult<ModelDetails> {
518 let ModelRequestOptions {
519 models_dir,
520 assume_models,
521 fallbacks,
522 } = options;
523 match assume_models {
524 | true => Ok(self.assume(models_dir)),
525 | false => {
526 let direct_path = match &self.source {
527 | Source::Local { path, .. } if path.is_file() || Self::is_gguf(path) => Some(path.clone()),
528 | _ => None,
529 };
530 match direct_path {
531 | Some(path) => self.resolve_direct(&path),
532 | None => match self.resolve_repository(models_dir, &self.id) {
533 | Ok(model) => Ok(model),
534 | Err(direct_error) if fallbacks.is_empty() => Err(direct_error),
535 | Err(direct_error) => {
536 match fallbacks.iter().try_fold(Vec::new(), |failures, repository| {
537 match self.resolve_repository(models_dir, repository) {
538 | Ok(model) => ControlFlow::Break(model),
539 | Err(why) => {
540 ControlFlow::Continue(failures.into_iter().chain([(repository.clone(), why.to_string())]).collect::<Vec<_>>())
541 }
542 }
543 }) {
544 | ControlFlow::Break(model) => Ok(model),
545 | ControlFlow::Continue(failures) => Err(eyre!(
546 "{direct_error}; fallback attempts: {}",
547 failures
548 .into_iter()
549 .map(|(repository, why)| format!("{repository} ({why})"))
550 .collect::<Vec<_>>()
551 .join("; ")
552 )),
553 }
554 }
555 },
556 }
557 }
558 }
559 }
560 fn resolve_repository(&self, models_dir: &Path, repository: &str) -> ApiResult<ModelDetails> {
561 let Self { id, filter, ignore, .. } = self;
562 resolve_gguf(&models_dir.display().to_string(), repository, Some(filter), Some(ignore)).and_then(|models| match models.as_slice() {
563 | [model] => model
564 .path
565 .as_ref()
566 .map(PathBuf::from)
567 .ok_or_else(|| eyre!("Resolved model '{id}' has no GGUF path"))
568 .and_then(|path| self.details(path)),
569 | [] => Err(eyre!("No GGUF candidates resolved for model '{id}'")),
570 | _ => Err(eyre!(ShardError::Ambiguous {
571 model: id.clone(),
572 paths: models.iter().filter_map(|model| model.path.clone()).collect(),
573 })),
574 })
575 }
576 fn resolve_direct(&self, path: &Path) -> ApiResult<ModelDetails> {
577 match (path.is_file(), Self::is_gguf(path)) {
578 | (false, _) => Err(eyre!("Direct local GGUF source for '{}' does not exist — {}", self.id, path.display())),
579 | (_, true) => FilterSet::filter(
580 vec![path.to_path_buf()],
581 &self.filter,
582 &self.ignore,
583 |path| path.file_name().and_then(|value| value.to_str()).unwrap_or("").to_string(),
584 |_| true,
585 )
586 .and_then(|paths| match paths.as_slice() {
587 | [path] => self.details(path.clone()),
588 | _ => Err(eyre!("Direct GGUF source for '{}' was excluded by filter/ignore patterns", self.id)),
589 }),
590 | _ => Err(eyre!(
591 "Direct local model source for '{}' is not a GGUF file — {}",
592 self.id,
593 path.display()
594 )),
595 }
596 }
597}
598impl TryFrom<&ModelEntry> for ModelRequest {
599 type Error = color_eyre::Report;
600 fn try_from(entry: &ModelEntry) -> Result<Self, Self::Error> {
601 match entry {
602 | ModelEntry::Selector(selector) => {
603 let selector = selector.trim();
604 let source = Source::from(selector);
605 let id = match &source {
606 | Source::Local { path, .. } if path.is_file() || Self::is_gguf(path) => source.name(),
607 | _ => selector.to_string(),
608 };
609 Self::new(id, source, Vec::new(), Vec::new())
610 }
611 | ModelEntry::Entry(options) => Self::new(
612 options.name.clone(),
613 Source::from(&options.source).with_name(options.name.as_str()),
614 options.filter.clone().unwrap_or_default(),
615 options.ignore.clone().unwrap_or_default(),
616 ),
617 }
618 }
619}
620impl From<PathBuf> for Shard {
621 fn from(path: PathBuf) -> Self {
622 let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("").to_string();
623 let parts = Self::parts(&filename);
624 let malformed = Self::is_sharded(&filename) && parts.is_none();
625 Self {
626 key: parts.as_ref().map_or_else(|| filename.clone(), |(key, _, _)| key.clone()),
627 index: parts.as_ref().map(|(_, index, _)| *index),
628 count: parts.map(|(_, _, count)| count),
629 malformed,
630 path,
631 }
632 }
633}
634impl fmt::Display for SyncError {
635 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
636 match self {
637 | Self::Goose(why) => write!(formatter, "Synchronize Goose — {why}"),
638 | Self::LlamaSwap(why) => write!(formatter, "Synchronize llama-swap — {why}"),
639 | Self::OpenCode(why) => write!(formatter, "Synchronize OpenCode — {why}"),
640 | Self::VsCode(why) => write!(formatter, "Synchronize VS Code — {why}"),
641 }
642 }
643}
644impl core::error::Error for SyncError {}
645impl fmt::Display for Shard {
646 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
647 self.path.display().fmt(formatter)
648 }
649}
650impl fmt::Display for ShardError {
651 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
652 match self {
653 | Self::Ambiguous { model, paths } => {
654 write!(
655 formatter,
656 "Multiple independent GGUF candidates resolved for model '{model}': {}",
657 paths.join(", ")
658 )
659 }
660 | Self::Canonicalize { model, why } => {
661 write!(formatter, "Failed to resolve absolute GGUF path for '{model}': {why}")
662 }
663 | Self::Malformed(shard) => write!(formatter, "Malformed GGUF shard filename '{shard}'"),
664 | Self::EmptyCandidate => formatter.write_str("GGUF candidate group is empty"),
665 | Self::EmptyGroup => formatter.write_str("GGUF shard group is empty"),
666 | Self::Incomplete { key, count } => {
667 write!(
668 formatter,
669 "Incomplete or inconsistent GGUF shard set '{key}' — expected shards 1 through {count}"
670 )
671 }
672 }
673 }
674}
675impl core::error::Error for ShardError {}
676impl From<Vec<PathBuf>> for Shards {
677 fn from(paths: Vec<PathBuf>) -> Self {
678 Self(paths.into_iter().map(Shard::from).collect())
679 }
680}
681impl Shard {
682 fn is_sharded(filename: &str) -> bool {
683 filename
684 .strip_suffix(".gguf")
685 .and_then(|stem| stem.rsplit_once("-of-"))
686 .and_then(|(before_count, count)| before_count.rsplit_once('-').map(|(base, index)| (base, index, count)))
687 .is_some_and(|(base, index, count)| {
688 !base.is_empty()
689 && !index.is_empty()
690 && index.chars().all(|c| c.is_ascii_digit())
691 && !count.is_empty()
692 && count.chars().all(|c| c.is_ascii_digit())
693 })
694 }
695 pub(crate) fn parts(filename: &str) -> Option<(String, u64, u64)> {
696 filename
697 .strip_suffix(".gguf")
698 .and_then(|stem| stem.rsplit_once("-of-"))
699 .filter(|(_, count)| !count.is_empty() && count.chars().all(|character| character.is_ascii_digit()))
700 .and_then(|(before_count, count)| {
701 let count_label = count.to_string();
702 before_count
703 .rsplit_once('-')
704 .filter(|(base, index)| !base.is_empty() && !index.is_empty() && index.chars().all(|character| character.is_ascii_digit()))
705 .and_then(|(base, index)| {
706 index
707 .parse::<u64>()
708 .ok()
709 .zip(count.parse::<u64>().ok())
710 .filter(|(index, count)| *index > 0 && *count > 0 && index <= count)
711 .map(|(index, count)| (format!("{base}-of-{count_label}"), index, count))
712 })
713 })
714 }
715}
716impl Shards {
717 fn candidates(self) -> ApiResult<Vec<PathBuf>> {
718 match self.0.iter().find(|shard| shard.malformed) {
719 | Some(_) => self.0.into_iter().find(|shard| shard.malformed).map_or_else(
720 || Err(eyre!(ShardError::EmptyCandidate)),
721 |shard| Err(eyre!(ShardError::Malformed(shard))),
722 ),
723 | None => self
724 .0
725 .into_iter()
726 .fold(BTreeMap::<String, Self>::new(), |mut groups, shard| {
727 groups.entry(shard.key.clone()).or_default().0.push(shard);
728 groups
729 })
730 .into_values()
731 .map(|group| {
732 let expected_count = group.0.iter().find_map(|shard| shard.count);
733 match expected_count {
734 | None => group
735 .0
736 .into_iter()
737 .map(|shard| shard.path)
738 .min()
739 .ok_or_else(|| eyre!(ShardError::EmptyCandidate)),
740 | Some(count) => {
741 let indexes = group.0.iter().filter_map(|shard| shard.index).collect::<BTreeSet<_>>();
742 let expected = (1..=count).collect::<BTreeSet<_>>();
743 match indexes == expected && usize::try_from(count).ok() == Some(group.0.len()) {
744 | true => group
745 .0
746 .into_iter()
747 .min_by_key(|shard| shard.index)
748 .map(|shard| shard.path)
749 .ok_or_else(|| eyre!(ShardError::EmptyGroup)),
750 | false => Err(eyre!(ShardError::Incomplete {
751 key: group.0.first().map_or_else(|| "unknown".to_string(), |shard| shard.key.clone()),
752 count,
753 })),
754 }
755 }
756 }
757 })
758 .collect(),
759 }
760 }
761}
762pub fn resolve_gguf(models_directory: &str, model_name: &str, filter: Option<&[String]>, ignore: Option<&[String]>) -> ApiResult<Vec<ModelDetails>> {
768 let model_dir = PathBuf::from(models_directory).join(model_name);
769 match model_dir.is_dir() {
770 | false => Err(eyre!("Model directory does not exist — {}", model_dir.display())),
771 | true => {
772 let gguf_files = files_all(model_dir.clone(), Some(vec!["gguf"]));
773 match gguf_files.is_empty() {
774 | true => Err(eyre!("No GGUF files found in model directory — {}", model_dir.display())),
775 | false => {
776 let filter = FilterSet::filter(
777 gguf_files,
778 filter.unwrap_or(&[]),
779 ignore.unwrap_or(&[]),
780 |path| path.file_name().and_then(|value| value.to_str()).unwrap_or("").to_string(),
781 |_| true,
782 );
783 filter.and_then(|filtered| match filtered.is_empty() {
784 | true => Err(eyre!("All GGUF files for '{}' were excluded by filter/ignore patterns", model_name)),
785 | false => Shards::from(filtered).candidates().map(|paths| {
786 paths
787 .into_iter()
788 .map(|path| {
789 ModelDetails::init()
790 .id(model_name)
791 .name(model_name)
792 .path(path.display().to_string())
793 .build()
794 })
795 .collect()
796 }),
797 })
798 }
799 }
800 }
801 }
802}
803#[cfg(test)]
804mod tests {
805 #![allow(
806 clippy::unwrap_used,
807 clippy::expect_used,
808 clippy::panic,
809 clippy::indexing_slicing,
810 clippy::arithmetic_side_effects
811 )]
812 use super::*;
813 use crate::io::config::{ApplicationConfiguration, ModelEntry, ModelEntryOptions};
814 use crate::prelude::{create_dir_all, read_to_string, remove_dir_all, write};
815 use crate::test::utils::temp_dir;
816 use crate::{Location, Repository};
817
818 fn detailed_model(name: &str, filter: Option<Vec<String>>) -> ModelEntry {
819 ModelEntry::Entry(ModelEntryOptions {
820 name: name.to_string(),
821 source: Repository::HuggingFace {
822 location: Location::Simple(format!("https://huggingface.co/example/{name}")),
823 },
824 revision: None,
825 auth: None,
826 filter,
827 ignore: None,
828 quantization: None,
829 gpu_memory: None,
830 copy: None,
831 symlink: None,
832 })
833 }
834 fn resolve_models(configuration: &ApplicationConfiguration, models_dir: &Path) -> ApiResult<Vec<ModelDetails>> {
835 ModelEntry::requests(configuration.models.as_deref().unwrap_or_default()).and_then(|requests| {
836 let options = ModelRequestOptions {
837 models_dir,
838 assume_models: false,
839 fallbacks: Vec::new(),
840 };
841 requests.into_iter().map(|request| request.resolve(&options)).collect()
842 })
843 }
844
845 #[test]
846 fn test_application_config_deserializes_sync_paths_without_serializing_them() {
847 let configuration = ApplicationConfiguration::parse(
848 r#"{
849 "config": {
850 "llamaSwap": {"path": "./llama-swap.yaml"},
851 "opencode": {"path": "./opencode.jsonc"},
852 "vscode": {"path": "./chatLanguageModels.json"},
853 "goose": {"path": "./goose.yaml"}
854 }
855 }"#,
856 )
857 .unwrap();
858 let config = configuration.config.unwrap();
859 assert_eq!(
860 config.llama_swap.as_ref().and_then(|value| value.path.as_deref()),
861 Some("./llama-swap.yaml")
862 );
863 assert_eq!(config.opencode.as_ref().and_then(|value| value.path.as_deref()), Some("./opencode.jsonc"));
864 assert_eq!(
865 config.vscode.as_ref().and_then(|value| value.path.as_deref()),
866 Some("./chatLanguageModels.json")
867 );
868 assert_eq!(config.goose.as_ref().and_then(|value| value.path.as_deref()), Some("./goose.yaml"));
869 let serialized = serde_json::to_value(config).unwrap();
870 assert!(serialized["llamaSwap"].get("path").is_none());
871 assert!(serialized["opencode"].get("path").is_none());
872 assert!(serialized["vscode"].get("path").is_none());
873 assert!(serialized["goose"].get("path").is_none());
874 }
875 #[test]
876 fn test_application_config_rejects_unknown_model_override_fields() {
877 let configuration = ApplicationConfiguration::parse(
878 r#"{
879 "config": {
880 "llamaSwap": {
881 "models": {"qwen": {"unknownOption": true}}
882 }
883 }
884 }"#,
885 );
886 assert!(configuration.is_err());
887 }
888 #[test]
889 fn test_commit_creates_parent_directories() {
890 let dir = temp_dir("sync-output");
891 let path = dir.join("nested").join("config.yaml");
892 let result = Config::commit(&[RenderedOutput {
893 target: "test",
894 path: path.clone(),
895 before: String::new(),
896 content: "models: {}".to_string(),
897 }]);
898 assert!(result.is_ok());
899 assert!(path.is_file());
900 let _ = remove_dir_all(dir);
901 }
902 #[test]
903 fn test_commit_rolls_back_when_a_later_rename_fails() {
904 let dir = temp_dir("sync-rollback");
905 create_dir_all(&dir).unwrap();
906 let first = dir.join("first.yaml");
907 let second = dir.join("second.jsonc");
908 write(&first, "original").unwrap();
909 create_dir_all(&second).unwrap();
910 let result = Config::commit(&[
911 RenderedOutput {
912 target: "first",
913 path: first.clone(),
914 before: "original".to_string(),
915 content: "updated".to_string(),
916 },
917 RenderedOutput {
918 target: "second",
919 path: second.clone(),
920 before: String::new(),
921 content: "{}".to_string(),
922 },
923 ]);
924 assert!(result.is_err());
925 assert_eq!(read_to_string(&first).unwrap(), "original");
926 assert!(second.is_dir());
927 assert!(!PathBuf::from(format!("{}.acorn-sync-tmp", first.display())).exists());
928 assert!(!PathBuf::from(format!("{}.acorn-sync-backup", first.display())).exists());
929 let _ = remove_dir_all(dir);
930 }
931 #[test]
932 fn test_dry_run_diff_is_colored_and_creates_no_directories() {
933 let dir = temp_dir("sync-dry-run");
934 let config = Config {
935 llama_swap: Some(llama_swap::Config {
936 path: Some(dir.join("nested").join("llama.yaml").display().to_string()),
937 ..Default::default()
938 }),
939 opencode: Some(opencode::Config {
940 path: Some(dir.join("nested").join("opencode.jsonc").display().to_string()),
941 ..Default::default()
942 }),
943 ..Default::default()
944 };
945 let models = [ModelDetails::init().id("qwen").name("qwen").path("/models/qwen.gguf").build()];
946 let output = config
947 .llama_swap
948 .as_ref()
949 .unwrap()
950 .render(Options {
951 models: &models,
952 dry_run: true,
953 ..Default::default()
954 })
955 .unwrap();
956 let changes = text_diff_changes_with_color(&output.before, &output.content, true);
957 assert!(changes
958 .iter()
959 .any(|(tag, line)| *tag == similar::ChangeTag::Insert && line.contains("+ models:")));
960 assert!(changes
961 .iter()
962 .filter(|(tag, _)| *tag == similar::ChangeTag::Insert)
963 .all(|(_, line)| line.contains("\u{1b}[32m")));
964 assert!(text_diff_changes_with_color(&output.before, &output.content, false)
965 .iter()
966 .all(|(_, line)| !line.contains('\u{1b}')));
967 assert!(config
968 .sync(Options {
969 models: &models,
970 dry_run: true,
971 force: true,
972 llama_swap: true,
973 ..Default::default()
974 })
975 .is_ok());
976 assert!(!dir.exists());
977 }
978 #[test]
979 fn test_render_failure_leaves_all_targets_unchanged() {
980 let dir = temp_dir("sync-render-failure");
981 create_dir_all(&dir).unwrap();
982 let llama_path = dir.join("llama.yaml");
983 let opencode_path = dir.join("opencode.jsonc");
984 write(&llama_path, "models: {}\n").unwrap();
985 write(&opencode_path, "{ invalid").unwrap();
986 let config = Config {
987 llama_swap: Some(llama_swap::Config {
988 path: Some(llama_path.display().to_string()),
989 ..Default::default()
990 }),
991 opencode: Some(opencode::Config {
992 path: Some(opencode_path.display().to_string()),
993 ..Default::default()
994 }),
995 ..Default::default()
996 };
997 let models = [ModelDetails::init().id("qwen").name("qwen").path("/models/qwen.gguf").build()];
998 assert!(config
999 .sync(Options {
1000 models: &models,
1001 force: true,
1002 llama_swap: true,
1003 opencode: true,
1004 ..Default::default()
1005 })
1006 .is_err());
1007 assert_eq!(read_to_string(&llama_path).unwrap(), "models: {}\n");
1008 assert_eq!(read_to_string(&opencode_path).unwrap(), "{ invalid");
1009 let _ = remove_dir_all(dir);
1010 }
1011 #[test]
1012 fn test_resolve_gguf_applies_filter_and_ignore_patterns() {
1013 let dir = temp_dir("resolve-gguf-filter");
1014 let model_dir = dir.join("test-model").join("nested");
1015 create_dir_all(&model_dir).unwrap();
1016 write(model_dir.join("alpha.gguf"), b"alpha").unwrap();
1017 write(model_dir.join("beta.gguf"), b"beta").unwrap();
1018 let filtered = resolve_gguf(&dir.display().to_string(), "test-model", Some(&["alpha".to_string()]), None).unwrap();
1019 let ignored = resolve_gguf(&dir.display().to_string(), "test-model", None, Some(&["alpha".to_string()])).unwrap();
1020 assert_eq!(filtered.len(), 1);
1021 assert!(filtered[0].path.as_deref().is_some_and(|path| path.ends_with("alpha.gguf")));
1022 assert_eq!(ignored.len(), 1);
1023 assert!(ignored[0].path.as_deref().is_some_and(|path| path.ends_with("beta.gguf")));
1024 let _ = remove_dir_all(dir);
1025 }
1026 #[test]
1027 fn test_resolve_gguf_fails_on_missing_dir() {
1028 let resolved = resolve_gguf("/nonexistent", "model", None, None);
1029 assert!(resolved.is_err());
1030 }
1031 #[test]
1032 fn test_resolve_gguf_fails_on_no_gguf_files() {
1033 let dir = temp_dir("resolve-gguf-empty");
1034 let model_dir = dir.join("empty-model");
1035 create_dir_all(&model_dir).unwrap();
1036 let resolved = resolve_gguf(&dir.display().to_string(), "empty-model", None, None);
1037 assert!(resolved.is_err());
1038 let _ = remove_dir_all(dir);
1039 }
1040 #[test]
1041 fn test_resolve_gguf_finds_files() {
1042 let dir = temp_dir("resolve-gguf");
1043 let model_dir = dir.join("test-model");
1044 create_dir_all(&model_dir).unwrap();
1045 write(model_dir.join("model.gguf"), b"fake").unwrap();
1046 let resolved = resolve_gguf(&dir.display().to_string(), "test-model", None, None);
1047 assert!(resolved.is_ok());
1048 let models = resolved.unwrap();
1049 assert_eq!(models.len(), 1);
1050 assert_eq!(models[0].name.as_deref(), Some("test-model"));
1051 let _ = remove_dir_all(dir);
1052 }
1053 #[test]
1054 fn test_resolve_models_applies_detailed_filters() {
1055 let dir = temp_dir("sync-detailed-filter");
1056 let model_dir = dir.join("qwen");
1057 create_dir_all(&model_dir).unwrap();
1058 write(model_dir.join("qwen-q4.gguf"), b"q4").unwrap();
1059 write(model_dir.join("qwen-q8.gguf"), b"q8").unwrap();
1060 let configuration = ApplicationConfiguration {
1061 models: Some(vec![detailed_model("qwen", Some(vec!["q4".to_string()]))]),
1062 ..Default::default()
1063 };
1064 let resolved = resolve_models(&configuration, &dir).unwrap();
1065 assert_eq!(resolved.len(), 1);
1066 assert!(resolved[0].path.as_deref().is_some_and(|path| path.ends_with("qwen-q4.gguf")));
1067 let _ = remove_dir_all(dir);
1068 }
1069 #[test]
1070 fn test_resolve_models_fails_for_missing_ambiguous_and_duplicate_models() {
1071 let dir = temp_dir("sync-resolution-errors");
1072 let ambiguous_dir = dir.join("ambiguous");
1073 create_dir_all(&ambiguous_dir).unwrap();
1074 write(ambiguous_dir.join("one.gguf"), b"one").unwrap();
1075 write(ambiguous_dir.join("two.gguf"), b"two").unwrap();
1076 let missing = ApplicationConfiguration {
1077 models: Some(vec![ModelEntry::Selector("missing".to_string())]),
1078 ..Default::default()
1079 };
1080 assert!(resolve_models(&missing, &dir).is_err());
1081 let missing_direct = ApplicationConfiguration {
1082 models: Some(vec![ModelEntry::Selector(dir.join("missing.gguf").display().to_string())]),
1083 ..Default::default()
1084 };
1085 assert!(resolve_models(&missing_direct, &dir).is_err());
1086 let ambiguous = ApplicationConfiguration {
1087 models: Some(vec![ModelEntry::Selector("ambiguous".to_string())]),
1088 ..Default::default()
1089 };
1090 assert!(resolve_models(&ambiguous, &dir).is_err());
1091 let duplicate = ApplicationConfiguration {
1092 models: Some(vec![detailed_model("duplicate", None), detailed_model("duplicate", None)]),
1093 ..Default::default()
1094 };
1095 assert!(resolve_models(&duplicate, &dir).is_err());
1096 let _ = remove_dir_all(dir);
1097 }
1098 #[test]
1099 fn test_resolve_model_reports_every_failed_fallback() {
1100 let dir = temp_dir("sync-fallback-errors");
1101 create_dir_all(&dir).unwrap();
1102 let request = ModelEntry::requests(&[ModelEntry::Selector("primary/model".to_string())])
1103 .unwrap()
1104 .into_iter()
1105 .next()
1106 .unwrap();
1107 let options = ModelRequestOptions {
1108 models_dir: &dir,
1109 assume_models: false,
1110 fallbacks: vec!["fallback/one".to_string(), "fallback/two".to_string()],
1111 };
1112 let error = request.resolve(&options).unwrap_err().to_string();
1113 assert!(error.contains("Model directory does not exist"));
1114 assert!(error.contains("fallback attempts:"));
1115 assert!(error.contains("fallback/one (Model directory does not exist"));
1116 assert!(error.contains("fallback/two (Model directory does not exist"));
1117 let _ = remove_dir_all(dir);
1118 }
1119 #[test]
1120 fn test_resolve_models_uses_direct_local_gguf_source() {
1121 let dir = temp_dir("sync-direct-gguf");
1122 create_dir_all(&dir).unwrap();
1123 let path = dir.join("direct.gguf");
1124 write(&path, b"gguf").unwrap();
1125 let configuration = ApplicationConfiguration {
1126 models: Some(vec![ModelEntry::Selector(path.display().to_string())]),
1127 ..Default::default()
1128 };
1129 let resolved = resolve_models(&configuration, &dir).unwrap();
1130 assert_eq!(resolved.len(), 1);
1131 assert_eq!(resolved[0].id.as_deref(), Some("direct"));
1132 assert_eq!(
1133 resolved[0].path.as_deref(),
1134 Some(path.canonicalize().unwrap().cross_platform_display().as_str())
1135 );
1136 let _ = remove_dir_all(dir);
1137 }
1138 #[test]
1139 fn test_shard_candidates_keep_first_complete_shard() {
1140 let dir = temp_dir("dedup-shards");
1141 create_dir_all(&dir).unwrap();
1142 let files = vec![
1143 dir.join("model-00001-of-00003.gguf"),
1144 dir.join("model-00002-of-00003.gguf"),
1145 dir.join("model-00003-of-00003.gguf"),
1146 dir.join("standalone.gguf"),
1147 dir.join("mixture-of-8.gguf"),
1148 ];
1149 let candidates = Shards::from(files).candidates().unwrap();
1150 assert_eq!(candidates.len(), 3);
1151 assert!(candidates.iter().any(|path| path.ends_with("model-00001-of-00003.gguf")));
1152 assert!(candidates.iter().any(|path| path.ends_with("mixture-of-8.gguf")));
1153 let _ = remove_dir_all(dir);
1154 }
1155 #[test]
1156 fn test_shard_candidates_reject_incomplete_set() {
1157 let files = vec![PathBuf::from("model-00001-of-00003.gguf"), PathBuf::from("model-00003-of-00003.gguf")];
1158 assert!(Shards::from(files).candidates().is_err());
1159 assert!(Shards::from(vec![PathBuf::from("model-00004-of-00003.gguf")]).candidates().is_err());
1160 }
1161 #[test]
1162 fn test_sync_is_stable_after_successful_two_target_write() {
1163 let dir = temp_dir("sync-stable");
1164 let llama_path = dir.join("llama.yaml");
1165 let opencode_path = dir.join("opencode.jsonc");
1166 let config = Config {
1167 llama_swap: Some(llama_swap::Config {
1168 path: Some(llama_path.display().to_string()),
1169 ..Default::default()
1170 }),
1171 opencode: Some(opencode::Config {
1172 path: Some(opencode_path.display().to_string()),
1173 ..Default::default()
1174 }),
1175 ..Default::default()
1176 };
1177 let models = [ModelDetails::init().id("qwen").name("qwen").path("/models/qwen.gguf").build()];
1178 assert!(config
1179 .sync(Options {
1180 models: &models,
1181 force: true,
1182 llama_swap: true,
1183 opencode: true,
1184 ..Default::default()
1185 })
1186 .is_ok());
1187 let first = (read_to_string(&llama_path).unwrap(), read_to_string(&opencode_path).unwrap());
1188 assert!(config
1189 .sync(Options {
1190 models: &models,
1191 force: true,
1192 llama_swap: true,
1193 opencode: true,
1194 ..Default::default()
1195 })
1196 .is_ok());
1197 assert_eq!(first, (read_to_string(&llama_path).unwrap(), read_to_string(&opencode_path).unwrap()));
1198 let _ = remove_dir_all(dir);
1199 }
1200 #[test]
1201 fn test_sync_writes_vscode_and_goose_targets() {
1202 let dir = temp_dir("sync-vscode-goose");
1203 let vscode_path = dir.join("chatLanguageModels.json");
1204 let goose_path = dir.join("goose.yaml");
1205 let config = Config {
1206 vscode: Some(vscode::Config {
1207 path: Some(vscode_path.display().to_string()),
1208 ..Default::default()
1209 }),
1210 goose: Some(goose::Config {
1211 path: Some(goose_path.display().to_string()),
1212 default_model: Some("qwen".to_string()),
1213 ..Default::default()
1214 }),
1215 ..Default::default()
1216 };
1217 let models = [ModelDetails::init().id("qwen").name("Qwen").path("/models/qwen.gguf").build()];
1218 config
1219 .sync(Options {
1220 models: &models,
1221 force: true,
1222 vscode: true,
1223 goose: true,
1224 ..Default::default()
1225 })
1226 .unwrap();
1227 let vscode = read_to_string(vscode_path).unwrap();
1228 let goose = read_to_string(goose_path).unwrap();
1229 assert!(vscode.contains("\"vendor\": \"customendpoint\""));
1230 assert!(vscode.contains("\"id\": \"qwen\""));
1231 assert!(goose.contains("active_provider: openai"));
1232 assert!(goose.contains("model: qwen"));
1233 let _ = remove_dir_all(dir);
1234 }
1235 #[test]
1236 fn test_sync_force_bypasses_detection_only_for_explicit_targets() {
1237 let config = Config::default();
1238 let implicit = config.selected(&Options::default());
1239 assert_eq!(implicit.llama_swap.is_some(), command_exists("llama-swap"));
1240 assert_eq!(implicit.opencode.is_some(), command_exists("opencode"));
1241 assert_eq!(implicit.vscode.is_some(), command_exists("code"));
1242 assert_eq!(implicit.goose.is_some(), command_exists("goose"));
1243 let forced = config.selected(&Options {
1244 force: true,
1245 ..Default::default()
1246 });
1247 assert_eq!(forced.llama_swap.is_some(), command_exists("llama-swap"));
1248 assert_eq!(forced.opencode.is_some(), command_exists("opencode"));
1249 assert_eq!(forced.vscode.is_some(), command_exists("code"));
1250 assert_eq!(forced.goose.is_some(), command_exists("goose"));
1251 let included = config.selected(&Options {
1252 opencode: true,
1253 vscode: true,
1254 ..Default::default()
1255 });
1256 assert!(included.llama_swap.is_none());
1257 assert_eq!(included.opencode.is_some(), command_exists("opencode"));
1258 assert_eq!(included.vscode.is_some(), command_exists("code"));
1259 assert!(included.goose.is_none());
1260 let forced_opencode = config.selected(&Options {
1261 force: true,
1262 opencode: true,
1263 ..Default::default()
1264 });
1265 assert!(forced_opencode.llama_swap.is_none());
1266 assert!(forced_opencode.opencode.is_some());
1267 assert!(forced_opencode.vscode.is_none());
1268 assert!(forced_opencode.goose.is_none());
1269 }
1270 #[test]
1271 fn test_non_llama_targets_fall_back_only_for_existing_model_directories() {
1272 let models_dir = temp_dir("sync-existing-model-identity");
1273 create_dir_all(models_dir.join("acme/unresolved")).unwrap();
1274 let entries = [
1275 ModelEntry::Selector("acme/resolved".to_string()),
1276 ModelEntry::Selector("acme/unresolved".to_string()),
1277 ModelEntry::Selector("acme/missing".to_string()),
1278 ];
1279 let resolved = [ModelDetails::init().id("acme/resolved").name("Resolved").build()];
1280 let options = Options {
1281 models: &resolved,
1282 entries: &entries,
1283 models_dir: Some(&models_dir),
1284 ..Default::default()
1285 };
1286 let opencode = Config {
1287 opencode: Some(opencode::Config::default()),
1288 ..Default::default()
1289 }
1290 .models_for_sync(&options)
1291 .unwrap();
1292 assert_eq!(opencode.len(), 2);
1293 assert_eq!(opencode[0].name.as_deref(), Some("Resolved"));
1294 assert_eq!(opencode[1].id.as_deref(), Some("acme/unresolved"));
1295 assert!(!opencode.iter().any(|model| model.id.as_deref() == Some("acme/missing")));
1296 let llama_swap = Config {
1297 llama_swap: Some(llama_swap::Config::default()),
1298 opencode: Some(opencode::Config::default()),
1299 ..Default::default()
1300 }
1301 .models_for_sync(&options)
1302 .unwrap();
1303 assert_eq!(llama_swap.len(), 1);
1304 assert_eq!(llama_swap[0].id.as_deref(), Some("acme/resolved"));
1305 let _ = remove_dir_all(models_dir);
1306 }
1307 #[test]
1308 fn test_sync_rejects_invalid_settings() {
1309 let invalid_config = Config {
1310 llama_swap: Some(llama_swap::Config {
1311 executable: Some(String::new()),
1312 ..Default::default()
1313 }),
1314 opencode: None,
1315 ..Default::default()
1316 };
1317 assert!(invalid_config
1318 .sync(Options {
1319 force: true,
1320 llama_swap: true,
1321 ..Default::default()
1322 })
1323 .is_err());
1324 let invalid_config = Config {
1325 llama_swap: None,
1326 opencode: Some(opencode::Config {
1327 default_model: Some("missing".to_string()),
1328 ..Default::default()
1329 }),
1330 ..Default::default()
1331 };
1332 let models = [ModelDetails::init().id("qwen").build()];
1333 assert!(invalid_config
1334 .sync(Options {
1335 models: &models,
1336 force: true,
1337 opencode: true,
1338 ..Default::default()
1339 })
1340 .is_err());
1341 }
1342 #[test]
1343 fn test_sync_rejects_reserved_args_and_unknown_overrides() {
1344 let reserved_args = Config {
1345 llama_swap: Some(llama_swap::Config {
1346 extra_args: Some(vec![llama_swap::Argument::from("--model")]),
1347 ..Default::default()
1348 }),
1349 opencode: None,
1350 ..Default::default()
1351 };
1352 assert!(reserved_args
1353 .sync(Options {
1354 force: true,
1355 llama_swap: true,
1356 ..Default::default()
1357 })
1358 .is_err());
1359 let unknown_override = Config {
1360 llama_swap: Some(llama_swap::Config {
1361 models: Some([("missing".to_string(), llama_swap::ModelOverride::default())].into_iter().collect()),
1362 ..Default::default()
1363 }),
1364 opencode: None,
1365 ..Default::default()
1366 };
1367 let models = [ModelDetails::init().id("qwen").build()];
1368 assert!(unknown_override
1369 .sync(Options {
1370 models: &models,
1371 force: true,
1372 llama_swap: true,
1373 ..Default::default()
1374 })
1375 .is_err());
1376 }
1377 #[test]
1378 fn test_sync_validation_ignores_unselected_target() {
1379 let invalid_llama_swap = Config {
1380 llama_swap: Some(llama_swap::Config {
1381 executable: Some(String::new()),
1382 ..Default::default()
1383 }),
1384 opencode: Some(opencode::Config::default()),
1385 ..Default::default()
1386 };
1387 let invalid_opencode = Config {
1388 llama_swap: Some(llama_swap::Config::default()),
1389 opencode: Some(opencode::Config {
1390 default_model: Some("missing".to_string()),
1391 ..Default::default()
1392 }),
1393 ..Default::default()
1394 };
1395 assert!(invalid_llama_swap
1396 .sync(Options {
1397 force: true,
1398 opencode: true,
1399 ..Default::default()
1400 })
1401 .is_ok());
1402 assert!(invalid_llama_swap
1403 .sync(Options {
1404 force: true,
1405 llama_swap: true,
1406 ..Default::default()
1407 })
1408 .is_err());
1409 assert!(invalid_opencode
1410 .sync(Options {
1411 force: true,
1412 llama_swap: true,
1413 ..Default::default()
1414 })
1415 .is_ok());
1416 assert!(invalid_opencode
1417 .sync(Options {
1418 force: true,
1419 opencode: true,
1420 ..Default::default()
1421 })
1422 .is_err());
1423 }
1424}