Skip to main content

acorn/io/sync/
mod.rs

1//! Configuration synchronization schemas
2//!
3//! Provides types for the `acorn sync` command, which resolves ACORN model
4//! entries to downloaded GGUF files and merges the corresponding models into
5//! llama-swap, OpenCode, VS Code, and Goose configuration.
6use 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/// Top-level synchronization configuration section
50///
51/// Added to `ApplicationConfiguration` as the optional `config` field.
52///
53/// ### Example
54///
55/// ```yaml
56/// config:
57///   llamaSwap:
58///     path: ./llama-swap.yaml
59///     modelsDirectory: ~/.models
60///     executable: llama-server
61///     contextSize: 8192
62///   opencode:
63///     path: ./opencode.jsonc
64///     baseUrl: http://localhost:8080/v1
65///     providerId: llama-swap
66///   vscode:
67///     path: ./chatLanguageModels.json
68///     url: http://localhost:8080/v1/chat/completions
69///   goose:
70///     path: ./goose.yaml
71///     host: http://localhost:8080
72/// ```
73#[skip_serializing_none]
74#[derive(Clone, Debug, Default, Deserialize, Serialize, Validate)]
75#[serde(rename_all = "camelCase")]
76pub struct Config {
77    /// Goose CLI configuration for local inference
78    #[validate(nested)]
79    pub goose: Option<goose::Config>,
80    /// llama-swap configuration for local inference
81    #[validate(nested)]
82    pub llama_swap: Option<llama_swap::Config>,
83    /// OpenCode configuration for local inference
84    #[validate(nested)]
85    pub opencode: Option<opencode::Config>,
86    /// VS Code custom endpoint configuration for local inference
87    #[validate(nested)]
88    pub vscode: Option<vscode::Config>,
89}
90/// Inputs controlling a configuration synchronization operation
91#[derive(Builder, Clone, Copy, Debug, Default)]
92#[builder(start_fn = init)]
93pub struct Options<'a> {
94    /// Resolved models to synchronize.
95    #[builder(default)]
96    pub models: &'a [ModelDetails],
97    /// Model configuration entries to resolve before synchronization.
98    #[builder(default)]
99    pub entries: &'a [ModelEntry],
100    /// Synchronize OpenCode configuration.
101    #[builder(default)]
102    pub opencode: bool,
103    /// Synchronize VS Code configuration.
104    #[builder(default)]
105    pub vscode: bool,
106    /// Synchronize Goose CLI configuration.
107    #[builder(default)]
108    pub goose: bool,
109    /// Synchronize llama-swap configuration.
110    #[builder(default)]
111    pub llama_swap: bool,
112    /// Preview configuration without writing files.
113    #[builder(default)]
114    pub dry_run: bool,
115    /// Disable color in dry-run output.
116    #[builder(default)]
117    pub no_color: bool,
118    /// Remove stale managed models.
119    #[builder(default)]
120    pub prune: bool,
121    /// Bypass command detection for explicitly selected targets.
122    #[builder(default)]
123    pub force: bool,
124    /// Assume model paths from their identifiers without checking the filesystem.
125    #[builder(default)]
126    pub assume_models: bool,
127    /// Models directory override used to resolve model entries.
128    pub models_dir: Option<&'a Path>,
129}
130/// A normalized model entry ready for local GGUF resolution.
131#[derive(Clone, Debug)]
132pub struct ModelRequest {
133    id: String,
134    source: Source,
135    filter: Vec<String>,
136    ignore: Vec<String>,
137}
138/// Filesystem and fallback inputs used to resolve a model request.
139#[derive(Clone, Debug)]
140pub struct ModelRequestOptions<'a> {
141    /// Root directory containing downloaded model repositories.
142    pub models_dir: &'a Path,
143    /// Assume the configured model path exists without checking the filesystem.
144    pub assume_models: bool,
145    /// Downloaded fallback repository identifiers to try after the configured identifier.
146    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/// A parsed GGUF file candidate used to validate and order split model shards.
163#[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    /// Merge populated runtime overrides into configured synchronization targets
190    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    /// Merge CLI-supported overrides into configured synchronization targets
205    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    /// Resolve the model directory from a CLI override, configuration, or the user default
220    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    /// Synchronize all selected configuration targets
232    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    /// Build model details from the configured source or expected models-directory path without checking the filesystem.
464    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    /// Return the configured model identifier.
491    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    /// Resolve this request to one local GGUF model.
517    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}
762/// Discover GGUF files beneath a model's expected directory
763///
764/// Given a `models_directory` and a `model_name`, searches for
765/// `<models_directory>/<model_name>/**/*.gguf` and returns the first match.
766/// For sharded models, returns the first shard of a consistently named set.
767pub 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}