Skip to main content

opencode_provider_manager/app/
import.rs

1//! Config import and export functionality.
2
3use super::error::{AppError, Result};
4use super::state::AppState;
5use crate::config_core::{ConfigLayer, ModelConfig, ModelLimit, OpenCodeConfig, ProviderConfig};
6use serde::Deserialize;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11const IMPORT_META_KEY: &str = "_opmImport";
12const IMPORTABLE_EXTENSIONS: &[&str] = &["json", "jsonc", "toml", "yaml", "yml"];
13
14/// Import configuration from an external opencode.json file.
15pub fn import_config(
16    state: &mut AppState,
17    path: &Path,
18    layer: ConfigLayer,
19    merge_mode: ImportMergeMode,
20) -> Result<()> {
21    let external_config = parse_import_path(path, None)?;
22    apply_import_config(state, external_config, layer, merge_mode)
23}
24
25/// Import a JSON/JSONC/TOML/YAML snippet into an app state layer.
26pub fn import_snippet(
27    state: &mut AppState,
28    snippet: &str,
29    provider_id_hint: Option<&str>,
30    source_label: Option<&str>,
31    layer: ConfigLayer,
32    merge_mode: ImportMergeMode,
33) -> Result<ImportSummary> {
34    let config = parse_import_snippet(snippet, provider_id_hint, source_label)?;
35    let summary = ImportSummary::from_config(&config);
36    apply_import_config(state, config, layer, merge_mode)?;
37    Ok(summary)
38}
39
40/// Import from a local path or supported URL.
41pub fn import_source(
42    state: &mut AppState,
43    source: &str,
44    provider_id_hint: Option<&str>,
45    layer: ConfigLayer,
46    merge_mode: ImportMergeMode,
47) -> Result<ImportSummary> {
48    let config = parse_import_source(source, provider_id_hint)?;
49    let summary = ImportSummary::from_config(&config);
50    apply_import_config(state, config, layer, merge_mode)?;
51    Ok(summary)
52}
53
54/// Parse an import source without mutating state.
55pub fn parse_import_source(source: &str, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
56    if is_url(source) {
57        parse_import_url(source, provider_id_hint)
58    } else {
59        let path = Path::new(source);
60        if path.exists() {
61            parse_import_path(path, provider_id_hint)
62        } else {
63            parse_import_snippet(source, provider_id_hint, Some("inline snippet"))
64        }
65    }
66}
67
68/// Parse an inline JSON/JSONC/TOML/YAML import snippet.
69pub fn parse_import_snippet(
70    snippet: &str,
71    provider_id_hint: Option<&str>,
72    source_label: Option<&str>,
73) -> Result<OpenCodeConfig> {
74    let value = parse_loose_value(snippet)?;
75    normalize_import_value(value, provider_id_hint, source_label)
76}
77
78/// Apply an already-normalized config to a layer.
79pub fn apply_import_config(
80    state: &mut AppState,
81    external_config: OpenCodeConfig,
82    layer: ConfigLayer,
83    merge_mode: ImportMergeMode,
84) -> Result<()> {
85    match merge_mode {
86        ImportMergeMode::Replace => match layer {
87            ConfigLayer::Global => state.global_config = Some(external_config),
88            ConfigLayer::Project => state.project_config = Some(external_config),
89            ConfigLayer::Custom => state.custom_config = Some(external_config),
90        },
91        ImportMergeMode::Merge => {
92            let target = match layer {
93                ConfigLayer::Global => &mut state.global_config,
94                ConfigLayer::Project => &mut state.project_config,
95                ConfigLayer::Custom => &mut state.custom_config,
96            };
97
98            match target {
99                Some(existing) => {
100                    *target = Some(crate::config_core::merge_two(
101                        existing.clone(),
102                        external_config,
103                    ));
104                }
105                None => {
106                    *target = Some(external_config);
107                }
108            }
109        }
110    }
111
112    state.recompute_merged();
113    state.mark_dirty(layer);
114    Ok(())
115}
116
117/// Export current merged config to a file.
118pub fn export_config(state: &AppState, path: &Path, export_scope: ExportScope) -> Result<()> {
119    let config = match export_scope {
120        ExportScope::Merged => &state.merged_config,
121        ExportScope::Global => state
122            .global_config
123            .as_ref()
124            .ok_or_else(|| AppError::State("No global config".to_string()))?,
125        ExportScope::Project => state
126            .project_config
127            .as_ref()
128            .ok_or_else(|| AppError::State("No project config".to_string()))?,
129        ExportScope::Custom => state
130            .custom_config
131            .as_ref()
132            .ok_or_else(|| AppError::State("No custom config".to_string()))?,
133    };
134
135    crate::config_core::jsonc::write_config(config, path)?;
136    Ok(())
137}
138
139fn parse_import_path(path: &Path, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
140    if path.is_dir() {
141        return parse_import_directory(path, provider_id_hint);
142    }
143
144    let content = std::fs::read_to_string(path)?;
145    let hint = provider_id_hint.or_else(|| path.file_stem().and_then(|s| s.to_str()));
146    parse_import_snippet(&content, hint, Some(&path.display().to_string()))
147}
148
149fn parse_import_directory(dir: &Path, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
150    let provider_toml = dir.join("provider.toml");
151    if provider_toml.exists() {
152        return parse_models_dev_directory(dir, provider_id_hint);
153    }
154
155    let mut merged = OpenCodeConfig::default();
156    for path in collect_importable_files(dir)? {
157        let parsed = parse_import_path(&path, provider_id_hint)?;
158        merged = crate::config_core::merge_two(merged, parsed);
159    }
160    if merged == OpenCodeConfig::default() {
161        return Err(AppError::Import(format!(
162            "No importable JSON/TOML/YAML files found in {}",
163            dir.display()
164        )));
165    }
166    Ok(merged)
167}
168
169fn parse_models_dev_directory(
170    dir: &Path,
171    provider_id_hint: Option<&str>,
172) -> Result<OpenCodeConfig> {
173    let provider_id = provider_id_hint
174        .map(str::to_string)
175        .or_else(|| dir.file_name().and_then(|s| s.to_str()).map(str::to_string))
176        .ok_or_else(|| AppError::Import("Provider directory has no usable name".to_string()))?;
177
178    let provider_content = std::fs::read_to_string(dir.join("provider.toml"))?;
179    let provider_value = parse_toml_value(&provider_content)?;
180    let mut provider = models_dev_provider_from_value(provider_value)?;
181
182    let models_dir = dir.join("models");
183    if models_dir.exists() {
184        for model_path in collect_importable_files(&models_dir)? {
185            let model_id = model_path
186                .file_stem()
187                .and_then(|s| s.to_str())
188                .ok_or_else(|| AppError::Import("Model file has no usable name".to_string()))?
189                .to_string();
190            let model_content = std::fs::read_to_string(&model_path)?;
191            let model_value = parse_loose_value(&model_content)?;
192            let model = model_from_value(model_value)?;
193            provider
194                .models
195                .get_or_insert_with(HashMap::new)
196                .insert(model_id, model);
197        }
198    }
199
200    config_from_provider(provider_id, provider, Some(&dir.display().to_string()))
201}
202
203fn parse_import_url(url: &str, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
204    // Try all possible branch/depth splits for GitHub tree URLs.
205    // Branch names can contain slashes (e.g. "feat/Volcano_Engine"), so a
206    // simple split would mis-parse the branch boundary.
207    if let Some(candidates) = parse_github_tree_candidates(url) {
208        // Try each candidate (shortest branch first) until one resolves.
209        // We probe the GitHub Contents API — a 404 means wrong split.
210        let mut last_err = None;
211        for (owner, repo, branch, path) in candidates {
212            match github_contents(&owner, &repo, &branch, &path) {
213                Ok(entries) => {
214                    // Found the right split — continue with the full parse
215                    return parse_github_directory_with_entries(
216                        &owner, &repo, &branch, &path,
217                        entries, provider_id_hint, url,
218                    );
219                }
220                Err(e) => {
221                    last_err = Some(e);
222                    continue;
223                }
224            }
225        }
226        // All candidates failed
227        return Err(last_err.unwrap_or_else(|| {
228            AppError::Import("Could not resolve GitHub tree URL".to_string())
229        }));
230    }
231
232    // Non-tree GitHub URL or non-GitHub URL
233    if let Some((_owner, _repo, _branch, _path, is_tree)) = parse_github_url(url) {
234        if !is_tree {
235            // Raw file URL — just download it
236            let text = http_get_text(url)?;
237            let stem_hint = url_path_stem(url);
238            let hint = provider_id_hint.or(stem_hint.as_deref());
239            return parse_import_snippet(&text, hint, Some(url));
240        }
241    }
242
243    let text = http_get_text(url)?;
244    let stem_hint = url_path_stem(url);
245    let hint = provider_id_hint.or(stem_hint.as_deref());
246    parse_import_snippet(&text, hint, Some(url))
247}
248
249/// For a GitHub tree URL, return all possible (owner, repo, branch, path)
250/// candidates ordered by shortest branch name first.
251fn parse_github_tree_candidates(url: &str) -> Option<Vec<(String, String, String, String)>> {
252    let rest = url.strip_prefix("https://github.com/")?;
253    let parts: Vec<&str> = rest.split('/').collect();
254    if parts.len() < 5 {
255        return None;
256    }
257    let owner = parts[0].to_string();
258    let repo = parts[1].to_string();
259    let kind = parts[2];
260    if kind != "tree" {
261        return None;
262    }
263
264    // parts[3..] = branch_segment_1 / branch_segment_2 / ... / path_remaining
265    let remaining = &parts[3..];
266    let mut candidates = Vec::new();
267    for depth in 1..remaining.len() {
268        let branch = remaining[..depth].join("/");
269        let path = remaining[depth..].join("/");
270        if !path.is_empty() {
271            candidates.push((owner.clone(), repo.clone(), branch, path));
272        }
273    }
274    // Sort by branch length (shortest first) to prefer simpler branch names
275    candidates.sort_by_key(|c| c.2.len());
276    Some(candidates)
277}
278
279fn parse_github_directory_with_entries(
280    owner: &str,
281    repo: &str,
282    branch: &str,
283    path: &str,
284    entries: Vec<GithubContentEntry>,
285    provider_id_hint: Option<&str>,
286    source_url: &str,
287) -> Result<OpenCodeConfig> {
288    let provider_entry = entries
289        .iter()
290        .find(|entry| entry.name == "provider.toml" && entry.download_url.is_some());
291
292    if let Some(provider_entry) = provider_entry {
293        let provider_id = provider_id_hint
294            .map(str::to_string)
295            .or_else(|| path.rsplit('/').next().map(str::to_string))
296            .ok_or_else(|| {
297                AppError::Import("GitHub provider path has no provider ID".to_string())
298            })?;
299
300        let provider_text = http_get_text(provider_entry.download_url.as_ref().unwrap())
301            .map_err(|e| {
302                AppError::Import(format!(
303                    "Failed to download provider.toml from GitHub: {e}"
304                ))
305            })?;
306        let mut provider = models_dev_provider_from_value(parse_toml_value(&provider_text)?)?;
307
308        let models_path = format!("{}/models", path.trim_end_matches('/'));
309        // models/ subdirectory is optional — don't error if it doesn't exist
310        if let Ok(model_entries) = github_contents(owner, repo, branch, &models_path) {
311            for model_entry in model_entries {
312                if model_entry.entry_type == "file" && is_importable_name(&model_entry.name) {
313                    let Some(download_url) = model_entry.download_url else {
314                        continue;
315                    };
316                    let model_id = Path::new(&model_entry.name)
317                        .file_stem()
318                        .and_then(|s| s.to_str())
319                        .ok_or_else(|| {
320                            AppError::Import("Model URL has no usable file name".to_string())
321                        })?
322                        .to_string();
323                    let model = model_from_value(
324                        parse_loose_value(
325                            &http_get_text(&download_url).map_err(|e| {
326                                AppError::Import(format!(
327                                    "Failed to download model file {model_id}: {e}"
328                                ))
329                            })?,
330                        )?,
331                    )?;
332                    provider
333                        .models
334                        .get_or_insert_with(HashMap::new)
335                        .insert(model_id, model);
336                }
337            }
338        }
339
340        return config_from_provider(provider_id, provider, Some(source_url));
341    }
342
343    let mut merged = OpenCodeConfig::default();
344    for entry in entries {
345        if entry.entry_type == "file" && is_importable_name(&entry.name) {
346            if let Some(download_url) = entry.download_url {
347                let text = http_get_text(&download_url).map_err(|e| {
348                    AppError::Import(format!(
349                        "Failed to download {} from GitHub: {e}",
350                        entry.name
351                    ))
352                })?;
353                let parsed = parse_import_snippet(
354                    &text,
355                    provider_id_hint,
356                    Some(&download_url),
357                )?;
358                merged = crate::config_core::merge_two(merged, parsed);
359            }
360        }
361    }
362    Ok(merged)
363}
364
365fn normalize_import_value(
366    value: Value,
367    provider_id_hint: Option<&str>,
368    source_label: Option<&str>,
369) -> Result<OpenCodeConfig> {
370    let mut config = if value.get("provider").is_some()
371        || value.get("$schema").is_some()
372        || value.get("model").is_some()
373        || value.get("smallModel").is_some()
374    {
375        serde_json::from_value::<OpenCodeConfig>(value)?
376    } else if looks_like_provider_map(&value) {
377        let providers = serde_json::from_value::<HashMap<String, ProviderConfig>>(value)?;
378        OpenCodeConfig {
379            provider: Some(providers),
380            ..Default::default()
381        }
382    } else if looks_like_provider(&value) {
383        let provider_id = provider_id_hint.ok_or_else(|| {
384            AppError::Import(
385                "Provider fragment needs a provider ID hint; pass --provider-id or import from a named file/directory".to_string(),
386            )
387        })?;
388        let provider = provider_from_value(value)?;
389        config_from_provider(provider_id.to_string(), provider, source_label)?
390    } else if looks_like_model(&value) {
391        let model_id = provider_id_hint.ok_or_else(|| {
392            AppError::Import(
393                "Model fragment needs an ID hint from --provider-id/path; wrap it in a provider.models object for direct import".to_string(),
394            )
395        })?;
396        let mut provider = ProviderConfig::default();
397        provider
398            .models
399            .get_or_insert_with(HashMap::new)
400            .insert(model_id.to_string(), model_from_value(value)?);
401        config_from_provider(model_id.to_string(), provider, source_label)?
402    } else {
403        return Err(AppError::Import(
404            "Snippet is not a full config, provider map, provider fragment, or model fragment"
405                .to_string(),
406        ));
407    };
408
409    attach_import_metadata(&mut config, source_label);
410    Ok(config)
411}
412
413fn config_from_provider(
414    provider_id: String,
415    provider: ProviderConfig,
416    source_label: Option<&str>,
417) -> Result<OpenCodeConfig> {
418    let mut providers = HashMap::new();
419    providers.insert(provider_id, provider);
420    let mut config = OpenCodeConfig {
421        provider: Some(providers),
422        ..Default::default()
423    };
424    attach_import_metadata(&mut config, source_label);
425    Ok(config)
426}
427
428fn provider_from_value(value: Value) -> Result<ProviderConfig> {
429    if is_models_dev_provider_value(&value) {
430        models_dev_provider_from_value(value)
431    } else {
432        Ok(serde_json::from_value(value)?)
433    }
434}
435
436fn models_dev_provider_from_value(value: Value) -> Result<ProviderConfig> {
437    let obj = value.as_object().ok_or_else(|| {
438        AppError::Import("models.dev provider metadata must be an object".to_string())
439    })?;
440    let mut provider = ProviderConfig {
441        name: obj.get("name").and_then(Value::as_str).map(str::to_string),
442        npm: obj.get("npm").and_then(Value::as_str).map(str::to_string),
443        ..Default::default()
444    };
445
446    let mut options = HashMap::new();
447    if let Some(api) = obj.get("api").and_then(Value::as_str) {
448        options.insert("baseURL".to_string(), Value::String(api.to_string()));
449    }
450    if !options.is_empty() {
451        provider.options = Some(options);
452    }
453
454    for (key, val) in obj {
455        if !matches!(key.as_str(), "name" | "npm" | "api" | "env" | "models") {
456            provider.extra.insert(key.clone(), val.clone());
457        }
458    }
459
460    if let Some(models) = obj.get("models").and_then(Value::as_object) {
461        let mut parsed_models = HashMap::new();
462        for (model_id, model_value) in models {
463            parsed_models.insert(model_id.clone(), model_from_value(model_value.clone())?);
464        }
465        provider.models = Some(parsed_models);
466    }
467
468    Ok(provider)
469}
470
471fn model_from_value(value: Value) -> Result<ModelConfig> {
472    let obj = value
473        .as_object()
474        .ok_or_else(|| AppError::Import("Model metadata must be an object".to_string()))?;
475    let mut model = ModelConfig {
476        name: obj.get("name").and_then(Value::as_str).map(str::to_string),
477        id: obj.get("id").and_then(Value::as_str).map(str::to_string),
478        ..Default::default()
479    };
480
481    if let Some(limit) = obj.get("limit").and_then(Value::as_object) {
482        model.limit = Some(ModelLimit {
483            context: limit.get("context").and_then(Value::as_u64),
484            output: limit.get("output").and_then(Value::as_u64),
485        });
486    }
487
488    if let Some(options) = obj.get("options").and_then(Value::as_object) {
489        model.options = Some(options.clone().into_iter().collect());
490    }
491
492    for (key, val) in obj {
493        if !matches!(
494            key.as_str(),
495            "name" | "id" | "limit" | "options" | "variants" | "disabled"
496        ) {
497            model.extra.insert(key.clone(), val.clone());
498        }
499    }
500
501    if let Some(parsed_variants) = obj.get("variants") {
502        model.variants = serde_json::from_value(parsed_variants.clone())?;
503    }
504    model.disabled = obj.get("disabled").and_then(Value::as_bool);
505
506    Ok(model)
507}
508
509fn parse_loose_value(content: &str) -> Result<Value> {
510    let trimmed = content.trim_start();
511    if (trimmed.starts_with('{') || trimmed.starts_with('['))
512        && let Ok(handler) = crate::config_core::jsonc::JsoncHandler::parse(content)
513    {
514        return serde_json::from_str(&handler.to_json_string()?).map_err(AppError::from);
515    }
516
517    if let Ok(value) = toml::from_str::<toml::Value>(content) {
518        return serde_json::to_value(value).map_err(AppError::from);
519    }
520
521    if let Ok(value) = serde_yaml::from_str::<Value>(content) {
522        return Ok(value);
523    }
524
525    parse_toml_value(content)
526}
527
528fn parse_toml_value(content: &str) -> Result<Value> {
529    let value = toml::from_str::<toml::Value>(content)
530        .map_err(|e| AppError::Import(format!("Could not parse as JSON, YAML, or TOML: {e}")))?;
531    serde_json::to_value(value).map_err(AppError::from)
532}
533
534fn looks_like_provider_map(value: &Value) -> bool {
535    value
536        .as_object()
537        .is_some_and(|obj| !obj.is_empty() && obj.values().all(looks_like_provider))
538}
539
540fn looks_like_provider(value: &Value) -> bool {
541    value.as_object().is_some_and(|obj| {
542        obj.contains_key("npm")
543            || obj.contains_key("options")
544            || obj.contains_key("models")
545            || obj.contains_key("api")
546            || obj.contains_key("env")
547    })
548}
549
550fn looks_like_model(value: &Value) -> bool {
551    value.as_object().is_some_and(|obj| {
552        obj.contains_key("limit")
553            || obj.contains_key("modalities")
554            || obj.contains_key("cost")
555            || obj.contains_key("family")
556    })
557}
558
559fn is_models_dev_provider_value(value: &Value) -> bool {
560    value
561        .as_object()
562        .is_some_and(|obj| obj.contains_key("api") || obj.contains_key("env"))
563}
564
565fn attach_import_metadata(config: &mut OpenCodeConfig, source_label: Option<&str>) {
566    let Some(source) = source_label else {
567        return;
568    };
569
570    config.extra.insert(
571        IMPORT_META_KEY.to_string(),
572        serde_json::json!({
573            "source": source,
574            "note": "Imported by opencode-provider-manager. Keep this metadata as provenance for future review."
575        }),
576    );
577}
578
579fn collect_importable_files(dir: &Path) -> Result<Vec<PathBuf>> {
580    let mut files = Vec::new();
581    for entry in std::fs::read_dir(dir)? {
582        let entry = entry?;
583        let path = entry.path();
584        if path.is_dir() {
585            files.extend(collect_importable_files(&path)?);
586        } else if path
587            .extension()
588            .and_then(|ext| ext.to_str())
589            .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
590        {
591            files.push(path);
592        }
593    }
594    Ok(files)
595}
596
597fn is_url(source: &str) -> bool {
598    source.starts_with("https://") || source.starts_with("http://")
599}
600
601fn is_importable_name(name: &str) -> bool {
602    Path::new(name)
603        .extension()
604        .and_then(|ext| ext.to_str())
605        .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
606}
607
608fn http_get_text(url: &str) -> Result<String> {
609    reqwest::blocking::Client::new()
610        .get(url)
611        .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
612        .send()?
613        .error_for_status()?
614        .text()
615        .map_err(AppError::from)
616}
617
618fn parse_github_url(url: &str) -> Option<(String, String, String, String, bool)> {
619    let rest = url.strip_prefix("https://github.com/")?;
620    let mut parts = rest.split('/');
621    let owner = parts.next()?.to_string();
622    let repo = parts.next()?.to_string();
623    let kind = parts.next()?;
624    let branch = parts.next()?.to_string();
625    let path = parts.collect::<Vec<_>>().join("/");
626    if path.is_empty() {
627        return None;
628    }
629    Some((owner, repo, branch, path, kind == "tree"))
630}
631
632fn url_path_stem(url: &str) -> Option<String> {
633    url.rsplit('/')
634        .next()
635        .and_then(|name| Path::new(name).file_stem())
636        .and_then(|stem| stem.to_str())
637        .map(str::to_string)
638}
639
640fn github_contents(
641    owner: &str,
642    repo: &str,
643    branch: &str,
644    path: &str,
645) -> Result<Vec<GithubContentEntry>> {
646    let api_url =
647        format!("https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}");
648    let response = reqwest::blocking::Client::new()
649        .get(&api_url)
650        .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
651        .send()
652        .map_err(|e| {
653            AppError::Import(format!(
654                "Failed to reach GitHub API ({}): {e}",
655                github_short_path(owner, repo, branch, path)
656            ))
657        })?;
658
659    let status = response.status();
660    if status == reqwest::StatusCode::NOT_FOUND {
661        return Err(AppError::Import(format!(
662            "GitHub path not found: {} (branch: {branch})",
663            github_short_path(owner, repo, branch, path)
664        )));
665    }
666    if status == reqwest::StatusCode::FORBIDDEN {
667        return Err(AppError::Import(format!(
668            "GitHub API rate limit hit. Unauthenticated requests are limited to 60/hour.\n  \
669             Set GITHUB_TOKEN env var or wait and retry.\n  \
670             Path: {}",
671            github_short_path(owner, repo, branch, path)
672        )));
673    }
674    if !status.is_success() {
675        return Err(AppError::Import(format!(
676            "GitHub API returned {status} for: {}",
677            github_short_path(owner, repo, branch, path)
678        )));
679    }
680
681    let text = response.text().map_err(|e| {
682        AppError::Import(format!(
683            "Failed to read GitHub response: {e}"
684        ))
685    })?;
686    serde_json::from_str::<Vec<GithubContentEntry>>(&text).map_err(|e| {
687        AppError::Import(format!(
688            "Failed to parse GitHub directory listing: {e}\n  \
689             The path may point to a file, not a directory. Try a raw file URL instead."
690        ))
691    })
692}
693
694fn github_short_path(owner: &str, repo: &str, branch: &str, path: &str) -> String {
695    format!("{owner}/{repo}/{branch}/{path}")
696}
697
698#[derive(Debug, Deserialize)]
699struct GithubContentEntry {
700    name: String,
701    #[serde(rename = "type")]
702    entry_type: String,
703    download_url: Option<String>,
704}
705
706/// Summary of an import payload.
707#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct ImportSummary {
709    pub provider_count: usize,
710    pub model_count: usize,
711    pub provider_ids: Vec<String>,
712}
713
714impl ImportSummary {
715    pub fn from_config(config: &OpenCodeConfig) -> Self {
716        let mut provider_ids = Vec::new();
717        let mut model_count = 0;
718
719        if let Some(providers) = &config.provider {
720            for (provider_id, provider) in providers {
721                provider_ids.push(provider_id.clone());
722                model_count += provider.models.as_ref().map(HashMap::len).unwrap_or(0);
723            }
724        }
725        provider_ids.sort();
726
727        Self {
728            provider_count: provider_ids.len(),
729            model_count,
730            provider_ids,
731        }
732    }
733}
734
735/// How to handle conflicts during import.
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub enum ImportMergeMode {
738    /// Replace the entire config at the target layer.
739    Replace,
740    /// Deep merge the imported config into the existing config.
741    Merge,
742}
743
744/// What scope to export.
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub enum ExportScope {
747    /// Export the merged result.
748    Merged,
749    /// Export only the global config.
750    Global,
751    /// Export only the project config.
752    Project,
753    /// Export only the custom config (from --config / OPENCODE_CONFIG).
754    Custom,
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use tempfile::{NamedTempFile, tempdir};
761
762    #[test]
763    fn test_export_merged_config() {
764        let state = AppState::new().unwrap();
765        let temp_file = NamedTempFile::new().unwrap();
766        export_config(&state, temp_file.path(), ExportScope::Merged).unwrap();
767
768        let content = std::fs::read_to_string(temp_file.path()).unwrap();
769        assert!(content.contains("{"));
770    }
771
772    #[test]
773    fn test_parse_full_json_config_preserves_modalities() {
774        let config = parse_import_snippet(
775            r#"{
776              "provider": {
777                "volcengine-plan": {
778                  "npm": "@ai-sdk/openai-compatible",
779                  "models": {
780                    "glm-5.1": {
781                      "name": "glm-5.1",
782                      "limit": { "context": 200000, "output": 4096 },
783                      "modalities": { "input": ["text"], "output": ["text"] }
784                    }
785                  }
786                }
787              }
788            }"#,
789            None,
790            Some("test"),
791        )
792        .unwrap();
793
794        let model = config.provider.unwrap()["volcengine-plan"]
795            .models
796            .as_ref()
797            .unwrap()["glm-5.1"]
798            .clone();
799        assert_eq!(model.limit.unwrap().context, Some(200000));
800        assert!(model.extra.contains_key("modalities"));
801    }
802
803    #[test]
804    fn test_parse_provider_fragment_with_hint() {
805        let config = parse_import_snippet(
806            r#"{
807              "npm": "@ai-sdk/openai-compatible",
808              "name": "Volcano Engine",
809              "options": { "baseURL": "https://example.com/v1" }
810            }"#,
811            Some("volcengine-plan"),
812            Some("fragment"),
813        )
814        .unwrap();
815
816        assert!(config.provider.unwrap().contains_key("volcengine-plan"));
817    }
818
819    #[test]
820    fn test_parse_models_dev_directory() {
821        let dir = tempdir().unwrap();
822        std::fs::write(
823            dir.path().join("provider.toml"),
824            r#"
825name = "Xiaomi Token Plan (China)"
826env = ["XIAOMI_API_KEY"]
827npm = "@ai-sdk/openai-compatible"
828api = "https://token-plan-cn.xiaomimimo.com/v1"
829doc = "https://platform.xiaomimimo.com/#/docs"
830"#,
831        )
832        .unwrap();
833        std::fs::create_dir(dir.path().join("models")).unwrap();
834        std::fs::write(
835            dir.path().join("models").join("mimo-v2-pro.toml"),
836            r#"
837name = "MiMo-V2-Pro"
838family = "mimo"
839
840[limit]
841context = 1_000_000
842output = 128_000
843
844[modalities]
845input = ["text"]
846output = ["text"]
847"#,
848        )
849        .unwrap();
850
851        let config = parse_import_path(dir.path(), Some("xiaomi-token-plan-cn")).unwrap();
852        let provider = &config.provider.unwrap()["xiaomi-token-plan-cn"];
853        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
854        assert_eq!(
855            provider
856                .options
857                .as_ref()
858                .unwrap()
859                .get("baseURL")
860                .and_then(Value::as_str),
861            Some("https://token-plan-cn.xiaomimimo.com/v1")
862        );
863        assert!(!provider.options.as_ref().unwrap().contains_key("apiKey"));
864        assert!(
865            provider
866                .models
867                .as_ref()
868                .unwrap()
869                .contains_key("mimo-v2-pro")
870        );
871    }
872
873    #[test]
874    fn test_parse_github_url_simple_branch() {
875        let result = parse_github_url(
876            "https://github.com/owner/repo/tree/main/providers/my-provider",
877        );
878        let (owner, repo, branch, path, is_tree) = result.unwrap();
879        assert_eq!(owner, "owner");
880        assert_eq!(repo, "repo");
881        assert_eq!(branch, "main");
882        assert_eq!(path, "providers/my-provider");
883        assert!(is_tree);
884    }
885
886    #[test]
887    fn test_parse_github_url_blob_not_tree() {
888        let result = parse_github_url(
889            "https://github.com/owner/repo/blob/main/file.toml",
890        );
891        let (_, _, _, _, is_tree) = result.unwrap();
892        assert!(!is_tree);
893    }
894
895    #[test]
896    fn test_github_tree_candidates_simple_branch() {
897        let candidates = parse_github_tree_candidates(
898            "https://github.com/owner/repo/tree/main/providers/my-provider",
899        ).unwrap();
900
901        // Should have candidates with different branch depths
902        assert!(!candidates.is_empty());
903
904        // First candidate (shortest branch) should be branch=main
905        let (owner, repo, branch, path) = &candidates[0];
906        assert_eq!(owner, "owner");
907        assert_eq!(repo, "repo");
908        assert_eq!(branch, "main");
909        assert_eq!(path, "providers/my-provider");
910    }
911
912    #[test]
913    fn test_github_tree_candidates_slash_branch() {
914        // URL: .../tree/feat/Volcano_Engine/providers/volcano_engine_cn
915        // branch could be "feat" or "feat/Volcano_Engine"
916        let candidates = parse_github_tree_candidates(
917            "https://github.com/shengjian20/models.dev/tree/feat/Volcano_Engine/providers/volcano_engine_cn",
918        ).unwrap();
919
920        // Should have multiple candidates
921        assert!(candidates.len() >= 2);
922
923        // Sorted by branch length, so "feat" (4) comes before "feat/Volcano_Engine" (18)
924        assert_eq!(candidates[0].2, "feat");
925        assert_eq!(candidates[0].3, "Volcano_Engine/providers/volcano_engine_cn");
926        assert_eq!(candidates[1].2, "feat/Volcano_Engine");
927        assert_eq!(candidates[1].3, "providers/volcano_engine_cn");
928    }
929
930    #[test]
931    fn test_github_tree_candidates_not_tree_url() {
932        let result = parse_github_tree_candidates(
933            "https://github.com/owner/repo/blob/main/file.toml",
934        );
935        assert!(result.is_none());
936    }
937
938    #[test]
939    fn test_github_tree_candidates_too_short() {
940        let result = parse_github_tree_candidates(
941            "https://github.com/owner/repo/tree/main",
942        );
943        assert!(result.is_none());
944    }
945}