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,
217                        &repo,
218                        &branch,
219                        &path,
220                        entries,
221                        provider_id_hint,
222                        url,
223                    );
224                }
225                Err(e) => {
226                    last_err = Some(e);
227                    continue;
228                }
229            }
230        }
231        // All candidates failed
232        return Err(last_err
233            .unwrap_or_else(|| AppError::Import("Could not resolve GitHub tree URL".to_string())));
234    }
235
236    // Non-tree GitHub URL or non-GitHub URL
237    if let Some((_owner, _repo, _branch, _path, is_tree)) = parse_github_url(url) {
238        if !is_tree {
239            // Raw file URL — just download it
240            let text = http_get_text(url)?;
241            let stem_hint = url_path_stem(url);
242            let hint = provider_id_hint.or(stem_hint.as_deref());
243            return parse_import_snippet(&text, hint, Some(url));
244        }
245    }
246
247    let text = http_get_text(url)?;
248    let stem_hint = url_path_stem(url);
249    let hint = provider_id_hint.or(stem_hint.as_deref());
250    parse_import_snippet(&text, hint, Some(url))
251}
252
253/// For a GitHub tree URL, return all possible (owner, repo, branch, path)
254/// candidates ordered by shortest branch name first.
255fn parse_github_tree_candidates(url: &str) -> Option<Vec<(String, String, String, String)>> {
256    let rest = url.strip_prefix("https://github.com/")?;
257    let parts: Vec<&str> = rest.split('/').collect();
258    if parts.len() < 5 {
259        return None;
260    }
261    let owner = parts[0].to_string();
262    let repo = parts[1].to_string();
263    let kind = parts[2];
264    if kind != "tree" {
265        return None;
266    }
267
268    // parts[3..] = branch_segment_1 / branch_segment_2 / ... / path_remaining
269    let remaining = &parts[3..];
270    let mut candidates = Vec::new();
271    for depth in 1..remaining.len() {
272        let branch = remaining[..depth].join("/");
273        let path = remaining[depth..].join("/");
274        if !path.is_empty() {
275            candidates.push((owner.clone(), repo.clone(), branch, path));
276        }
277    }
278    // Sort by branch length (shortest first) to prefer simpler branch names
279    candidates.sort_by_key(|c| c.2.len());
280    Some(candidates)
281}
282
283fn parse_github_directory_with_entries(
284    owner: &str,
285    repo: &str,
286    branch: &str,
287    path: &str,
288    entries: Vec<GithubContentEntry>,
289    provider_id_hint: Option<&str>,
290    source_url: &str,
291) -> Result<OpenCodeConfig> {
292    let provider_entry = entries
293        .iter()
294        .find(|entry| entry.name == "provider.toml" && entry.download_url.is_some());
295
296    if let Some(provider_entry) = provider_entry {
297        let provider_id = provider_id_hint
298            .map(str::to_string)
299            .or_else(|| path.rsplit('/').next().map(str::to_string))
300            .ok_or_else(|| {
301                AppError::Import("GitHub provider path has no provider ID".to_string())
302            })?;
303
304        let provider_text =
305            http_get_text(provider_entry.download_url.as_ref().unwrap()).map_err(|e| {
306                AppError::Import(format!("Failed to download provider.toml from GitHub: {e}"))
307            })?;
308        let mut provider = models_dev_provider_from_value(parse_toml_value(&provider_text)?)?;
309
310        let models_path = format!("{}/models", path.trim_end_matches('/'));
311        // models/ subdirectory is optional — don't error if it doesn't exist
312        if let Ok(model_entries) = github_contents(owner, repo, branch, &models_path) {
313            for model_entry in model_entries {
314                if model_entry.entry_type == "file" && is_importable_name(&model_entry.name) {
315                    let Some(download_url) = model_entry.download_url else {
316                        continue;
317                    };
318                    let model_id = Path::new(&model_entry.name)
319                        .file_stem()
320                        .and_then(|s| s.to_str())
321                        .ok_or_else(|| {
322                            AppError::Import("Model URL has no usable file name".to_string())
323                        })?
324                        .to_string();
325                    let model = model_from_value(parse_loose_value(
326                        &http_get_text(&download_url).map_err(|e| {
327                            AppError::Import(format!(
328                                "Failed to download model file {model_id}: {e}"
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(&text, provider_id_hint, Some(&download_url))?;
354                merged = crate::config_core::merge_two(merged, parsed);
355            }
356        }
357    }
358    Ok(merged)
359}
360
361fn normalize_import_value(
362    value: Value,
363    provider_id_hint: Option<&str>,
364    source_label: Option<&str>,
365) -> Result<OpenCodeConfig> {
366    let mut config = if value.get("provider").is_some()
367        || value.get("$schema").is_some()
368        || value.get("model").is_some()
369        || value.get("smallModel").is_some()
370    {
371        serde_json::from_value::<OpenCodeConfig>(value)?
372    } else if looks_like_provider_map(&value) {
373        let providers = serde_json::from_value::<HashMap<String, ProviderConfig>>(value)?;
374        OpenCodeConfig {
375            provider: Some(providers),
376            ..Default::default()
377        }
378    } else if looks_like_provider(&value) {
379        let provider_id = provider_id_hint.ok_or_else(|| {
380            AppError::Import(
381                "Provider fragment needs a provider ID hint; pass --provider-id or import from a named file/directory".to_string(),
382            )
383        })?;
384        let provider = provider_from_value(value)?;
385        config_from_provider(provider_id.to_string(), provider, source_label)?
386    } else if looks_like_model(&value) {
387        let model_id = provider_id_hint.ok_or_else(|| {
388            AppError::Import(
389                "Model fragment needs an ID hint from --provider-id/path; wrap it in a provider.models object for direct import".to_string(),
390            )
391        })?;
392        let mut provider = ProviderConfig::default();
393        provider
394            .models
395            .get_or_insert_with(HashMap::new)
396            .insert(model_id.to_string(), model_from_value(value)?);
397        config_from_provider(model_id.to_string(), provider, source_label)?
398    } else {
399        return Err(AppError::Import(
400            "Snippet is not a full config, provider map, provider fragment, or model fragment"
401                .to_string(),
402        ));
403    };
404
405    attach_import_metadata(&mut config, source_label);
406    Ok(config)
407}
408
409fn config_from_provider(
410    provider_id: String,
411    provider: ProviderConfig,
412    source_label: Option<&str>,
413) -> Result<OpenCodeConfig> {
414    let mut providers = HashMap::new();
415    providers.insert(provider_id, provider);
416    let mut config = OpenCodeConfig {
417        provider: Some(providers),
418        ..Default::default()
419    };
420    attach_import_metadata(&mut config, source_label);
421    Ok(config)
422}
423
424fn provider_from_value(value: Value) -> Result<ProviderConfig> {
425    if is_models_dev_provider_value(&value) {
426        models_dev_provider_from_value(value)
427    } else {
428        Ok(serde_json::from_value(value)?)
429    }
430}
431
432fn models_dev_provider_from_value(value: Value) -> Result<ProviderConfig> {
433    let obj = value.as_object().ok_or_else(|| {
434        AppError::Import("models.dev provider metadata must be an object".to_string())
435    })?;
436    let mut provider = ProviderConfig {
437        name: obj.get("name").and_then(Value::as_str).map(str::to_string),
438        npm: obj.get("npm").and_then(Value::as_str).map(str::to_string),
439        ..Default::default()
440    };
441
442    let mut options = HashMap::new();
443    if let Some(api) = obj.get("api").and_then(Value::as_str) {
444        options.insert("baseURL".to_string(), Value::String(api.to_string()));
445    }
446    if !options.is_empty() {
447        provider.options = Some(options);
448    }
449
450    for (key, val) in obj {
451        if !matches!(key.as_str(), "name" | "npm" | "api" | "env" | "models") {
452            provider.extra.insert(key.clone(), val.clone());
453        }
454    }
455
456    if let Some(models) = obj.get("models").and_then(Value::as_object) {
457        let mut parsed_models = HashMap::new();
458        for (model_id, model_value) in models {
459            parsed_models.insert(model_id.clone(), model_from_value(model_value.clone())?);
460        }
461        provider.models = Some(parsed_models);
462    }
463
464    Ok(provider)
465}
466
467fn model_from_value(value: Value) -> Result<ModelConfig> {
468    let obj = value
469        .as_object()
470        .ok_or_else(|| AppError::Import("Model metadata must be an object".to_string()))?;
471    let mut model = ModelConfig {
472        name: obj.get("name").and_then(Value::as_str).map(str::to_string),
473        id: obj.get("id").and_then(Value::as_str).map(str::to_string),
474        ..Default::default()
475    };
476
477    if let Some(limit) = obj.get("limit").and_then(Value::as_object) {
478        model.limit = Some(ModelLimit {
479            context: limit.get("context").and_then(Value::as_u64),
480            output: limit.get("output").and_then(Value::as_u64),
481        });
482    }
483
484    if let Some(options) = obj.get("options").and_then(Value::as_object) {
485        model.options = Some(options.clone().into_iter().collect());
486    }
487
488    for (key, val) in obj {
489        if !matches!(
490            key.as_str(),
491            "name" | "id" | "limit" | "options" | "variants" | "disabled"
492        ) {
493            model.extra.insert(key.clone(), val.clone());
494        }
495    }
496
497    if let Some(parsed_variants) = obj.get("variants") {
498        model.variants = serde_json::from_value(parsed_variants.clone())?;
499    }
500    model.disabled = obj.get("disabled").and_then(Value::as_bool);
501
502    Ok(model)
503}
504
505fn parse_loose_value(content: &str) -> Result<Value> {
506    let trimmed = content.trim_start();
507    if (trimmed.starts_with('{') || trimmed.starts_with('['))
508        && let Ok(handler) = crate::config_core::jsonc::JsoncHandler::parse(content)
509    {
510        return serde_json::from_str(&handler.to_json_string()?).map_err(AppError::from);
511    }
512
513    if let Ok(value) = toml::from_str::<toml::Value>(content) {
514        return serde_json::to_value(value).map_err(AppError::from);
515    }
516
517    if let Ok(value) = serde_yaml::from_str::<Value>(content) {
518        return Ok(value);
519    }
520
521    parse_toml_value(content)
522}
523
524fn parse_toml_value(content: &str) -> Result<Value> {
525    let value = toml::from_str::<toml::Value>(content)
526        .map_err(|e| AppError::Import(format!("Could not parse as JSON, YAML, or TOML: {e}")))?;
527    serde_json::to_value(value).map_err(AppError::from)
528}
529
530fn looks_like_provider_map(value: &Value) -> bool {
531    value
532        .as_object()
533        .is_some_and(|obj| !obj.is_empty() && obj.values().all(looks_like_provider))
534}
535
536fn looks_like_provider(value: &Value) -> bool {
537    value.as_object().is_some_and(|obj| {
538        obj.contains_key("npm")
539            || obj.contains_key("options")
540            || obj.contains_key("models")
541            || obj.contains_key("api")
542            || obj.contains_key("env")
543    })
544}
545
546fn looks_like_model(value: &Value) -> bool {
547    value.as_object().is_some_and(|obj| {
548        obj.contains_key("limit")
549            || obj.contains_key("modalities")
550            || obj.contains_key("cost")
551            || obj.contains_key("family")
552    })
553}
554
555fn is_models_dev_provider_value(value: &Value) -> bool {
556    value
557        .as_object()
558        .is_some_and(|obj| obj.contains_key("api") || obj.contains_key("env"))
559}
560
561fn attach_import_metadata(config: &mut OpenCodeConfig, source_label: Option<&str>) {
562    let Some(source) = source_label else {
563        return;
564    };
565
566    config.extra.insert(
567        IMPORT_META_KEY.to_string(),
568        serde_json::json!({
569            "source": source,
570            "note": "Imported by opencode-provider-manager. Keep this metadata as provenance for future review."
571        }),
572    );
573}
574
575fn collect_importable_files(dir: &Path) -> Result<Vec<PathBuf>> {
576    let mut files = Vec::new();
577    for entry in std::fs::read_dir(dir)? {
578        let entry = entry?;
579        let path = entry.path();
580        if path.is_dir() {
581            files.extend(collect_importable_files(&path)?);
582        } else if path
583            .extension()
584            .and_then(|ext| ext.to_str())
585            .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
586        {
587            files.push(path);
588        }
589    }
590    Ok(files)
591}
592
593fn is_url(source: &str) -> bool {
594    source.starts_with("https://") || source.starts_with("http://")
595}
596
597fn is_importable_name(name: &str) -> bool {
598    Path::new(name)
599        .extension()
600        .and_then(|ext| ext.to_str())
601        .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
602}
603
604fn http_get_text(url: &str) -> Result<String> {
605    reqwest::blocking::Client::new()
606        .get(url)
607        .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
608        .send()?
609        .error_for_status()?
610        .text()
611        .map_err(AppError::from)
612}
613
614fn parse_github_url(url: &str) -> Option<(String, String, String, String, bool)> {
615    let rest = url.strip_prefix("https://github.com/")?;
616    let mut parts = rest.split('/');
617    let owner = parts.next()?.to_string();
618    let repo = parts.next()?.to_string();
619    let kind = parts.next()?;
620    let branch = parts.next()?.to_string();
621    let path = parts.collect::<Vec<_>>().join("/");
622    if path.is_empty() {
623        return None;
624    }
625    Some((owner, repo, branch, path, kind == "tree"))
626}
627
628fn url_path_stem(url: &str) -> Option<String> {
629    url.rsplit('/')
630        .next()
631        .and_then(|name| Path::new(name).file_stem())
632        .and_then(|stem| stem.to_str())
633        .map(str::to_string)
634}
635
636fn github_contents(
637    owner: &str,
638    repo: &str,
639    branch: &str,
640    path: &str,
641) -> Result<Vec<GithubContentEntry>> {
642    let api_url =
643        format!("https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}");
644    let response = reqwest::blocking::Client::new()
645        .get(&api_url)
646        .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
647        .send()
648        .map_err(|e| {
649            AppError::Import(format!(
650                "Failed to reach GitHub API ({}): {e}",
651                github_short_path(owner, repo, branch, path)
652            ))
653        })?;
654
655    let status = response.status();
656    if status == reqwest::StatusCode::NOT_FOUND {
657        return Err(AppError::Import(format!(
658            "GitHub path not found: {} (branch: {branch})",
659            github_short_path(owner, repo, branch, path)
660        )));
661    }
662    if status == reqwest::StatusCode::FORBIDDEN {
663        return Err(AppError::Import(format!(
664            "GitHub API rate limit hit. Unauthenticated requests are limited to 60/hour.\n  \
665             Set GITHUB_TOKEN env var or wait and retry.\n  \
666             Path: {}",
667            github_short_path(owner, repo, branch, path)
668        )));
669    }
670    if !status.is_success() {
671        return Err(AppError::Import(format!(
672            "GitHub API returned {status} for: {}",
673            github_short_path(owner, repo, branch, path)
674        )));
675    }
676
677    let text = response
678        .text()
679        .map_err(|e| AppError::Import(format!("Failed to read GitHub response: {e}")))?;
680    serde_json::from_str::<Vec<GithubContentEntry>>(&text).map_err(|e| {
681        AppError::Import(format!(
682            "Failed to parse GitHub directory listing: {e}\n  \
683             The path may point to a file, not a directory. Try a raw file URL instead."
684        ))
685    })
686}
687
688fn github_short_path(owner: &str, repo: &str, branch: &str, path: &str) -> String {
689    format!("{owner}/{repo}/{branch}/{path}")
690}
691
692#[derive(Debug, Deserialize)]
693struct GithubContentEntry {
694    name: String,
695    #[serde(rename = "type")]
696    entry_type: String,
697    download_url: Option<String>,
698}
699
700/// Summary of an import payload.
701#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct ImportSummary {
703    pub provider_count: usize,
704    pub model_count: usize,
705    pub provider_ids: Vec<String>,
706}
707
708impl ImportSummary {
709    pub fn from_config(config: &OpenCodeConfig) -> Self {
710        let mut provider_ids = Vec::new();
711        let mut model_count = 0;
712
713        if let Some(providers) = &config.provider {
714            for (provider_id, provider) in providers {
715                provider_ids.push(provider_id.clone());
716                model_count += provider.models.as_ref().map(HashMap::len).unwrap_or(0);
717            }
718        }
719        provider_ids.sort();
720
721        Self {
722            provider_count: provider_ids.len(),
723            model_count,
724            provider_ids,
725        }
726    }
727}
728
729/// How to handle conflicts during import.
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub enum ImportMergeMode {
732    /// Replace the entire config at the target layer.
733    Replace,
734    /// Deep merge the imported config into the existing config.
735    Merge,
736}
737
738/// What scope to export.
739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740pub enum ExportScope {
741    /// Export the merged result.
742    Merged,
743    /// Export only the global config.
744    Global,
745    /// Export only the project config.
746    Project,
747    /// Export only the custom config (from --config / OPENCODE_CONFIG).
748    Custom,
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use tempfile::{NamedTempFile, tempdir};
755
756    #[test]
757    fn test_export_merged_config() {
758        let state = AppState::new().unwrap();
759        let temp_file = NamedTempFile::new().unwrap();
760        export_config(&state, temp_file.path(), ExportScope::Merged).unwrap();
761
762        let content = std::fs::read_to_string(temp_file.path()).unwrap();
763        assert!(content.contains("{"));
764    }
765
766    #[test]
767    fn test_parse_full_json_config_preserves_modalities() {
768        let config = parse_import_snippet(
769            r#"{
770              "provider": {
771                "volcengine-plan": {
772                  "npm": "@ai-sdk/openai-compatible",
773                  "models": {
774                    "glm-5.1": {
775                      "name": "glm-5.1",
776                      "limit": { "context": 200000, "output": 4096 },
777                      "modalities": { "input": ["text"], "output": ["text"] }
778                    }
779                  }
780                }
781              }
782            }"#,
783            None,
784            Some("test"),
785        )
786        .unwrap();
787
788        let model = config.provider.unwrap()["volcengine-plan"]
789            .models
790            .as_ref()
791            .unwrap()["glm-5.1"]
792            .clone();
793        assert_eq!(model.limit.unwrap().context, Some(200000));
794        assert!(model.extra.contains_key("modalities"));
795    }
796
797    #[test]
798    fn test_parse_provider_fragment_with_hint() {
799        let config = parse_import_snippet(
800            r#"{
801              "npm": "@ai-sdk/openai-compatible",
802              "name": "Volcano Engine",
803              "options": { "baseURL": "https://example.com/v1" }
804            }"#,
805            Some("volcengine-plan"),
806            Some("fragment"),
807        )
808        .unwrap();
809
810        assert!(config.provider.unwrap().contains_key("volcengine-plan"));
811    }
812
813    #[test]
814    fn test_parse_models_dev_directory() {
815        let dir = tempdir().unwrap();
816        std::fs::write(
817            dir.path().join("provider.toml"),
818            r#"
819name = "Xiaomi Token Plan (China)"
820env = ["XIAOMI_API_KEY"]
821npm = "@ai-sdk/openai-compatible"
822api = "https://token-plan-cn.xiaomimimo.com/v1"
823doc = "https://platform.xiaomimimo.com/#/docs"
824"#,
825        )
826        .unwrap();
827        std::fs::create_dir(dir.path().join("models")).unwrap();
828        std::fs::write(
829            dir.path().join("models").join("mimo-v2-pro.toml"),
830            r#"
831name = "MiMo-V2-Pro"
832family = "mimo"
833
834[limit]
835context = 1_000_000
836output = 128_000
837
838[modalities]
839input = ["text"]
840output = ["text"]
841"#,
842        )
843        .unwrap();
844
845        let config = parse_import_path(dir.path(), Some("xiaomi-token-plan-cn")).unwrap();
846        let provider = &config.provider.unwrap()["xiaomi-token-plan-cn"];
847        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
848        assert_eq!(
849            provider
850                .options
851                .as_ref()
852                .unwrap()
853                .get("baseURL")
854                .and_then(Value::as_str),
855            Some("https://token-plan-cn.xiaomimimo.com/v1")
856        );
857        assert!(!provider.options.as_ref().unwrap().contains_key("apiKey"));
858        assert!(
859            provider
860                .models
861                .as_ref()
862                .unwrap()
863                .contains_key("mimo-v2-pro")
864        );
865    }
866
867    #[test]
868    fn test_parse_github_url_simple_branch() {
869        let result =
870            parse_github_url("https://github.com/owner/repo/tree/main/providers/my-provider");
871        let (owner, repo, branch, path, is_tree) = result.unwrap();
872        assert_eq!(owner, "owner");
873        assert_eq!(repo, "repo");
874        assert_eq!(branch, "main");
875        assert_eq!(path, "providers/my-provider");
876        assert!(is_tree);
877    }
878
879    #[test]
880    fn test_parse_github_url_blob_not_tree() {
881        let result = parse_github_url("https://github.com/owner/repo/blob/main/file.toml");
882        let (_, _, _, _, is_tree) = result.unwrap();
883        assert!(!is_tree);
884    }
885
886    #[test]
887    fn test_github_tree_candidates_simple_branch() {
888        let candidates = parse_github_tree_candidates(
889            "https://github.com/owner/repo/tree/main/providers/my-provider",
890        )
891        .unwrap();
892
893        // Should have candidates with different branch depths
894        assert!(!candidates.is_empty());
895
896        // First candidate (shortest branch) should be branch=main
897        let (owner, repo, branch, path) = &candidates[0];
898        assert_eq!(owner, "owner");
899        assert_eq!(repo, "repo");
900        assert_eq!(branch, "main");
901        assert_eq!(path, "providers/my-provider");
902    }
903
904    #[test]
905    fn test_github_tree_candidates_slash_branch() {
906        // URL: .../tree/feat/Volcano_Engine/providers/volcano_engine_cn
907        // branch could be "feat" or "feat/Volcano_Engine"
908        let candidates = parse_github_tree_candidates(
909            "https://github.com/shengjian20/models.dev/tree/feat/Volcano_Engine/providers/volcano_engine_cn",
910        ).unwrap();
911
912        // Should have multiple candidates
913        assert!(candidates.len() >= 2);
914
915        // Sorted by branch length, so "feat" (4) comes before "feat/Volcano_Engine" (18)
916        assert_eq!(candidates[0].2, "feat");
917        assert_eq!(
918            candidates[0].3,
919            "Volcano_Engine/providers/volcano_engine_cn"
920        );
921        assert_eq!(candidates[1].2, "feat/Volcano_Engine");
922        assert_eq!(candidates[1].3, "providers/volcano_engine_cn");
923    }
924
925    #[test]
926    fn test_github_tree_candidates_not_tree_url() {
927        let result =
928            parse_github_tree_candidates("https://github.com/owner/repo/blob/main/file.toml");
929        assert!(result.is_none());
930    }
931
932    #[test]
933    fn test_github_tree_candidates_too_short() {
934        let result = parse_github_tree_candidates("https://github.com/owner/repo/tree/main");
935        assert!(result.is_none());
936    }
937}