Skip to main content

light_fw_cli_core/
publish.rs

1//! Команда `light publish`: оркестрация публикации крейтов воркспейса.
2//!
3//! Граф строится из `cargo metadata --no-deps`: модуль не парсит `Cargo.toml`
4//! руками для топологии. Внешние эффекты (запуск `cargo`/`git`, сеть, ожидание)
5//! спрятаны за трейтами [`CommandRunner`], [`RegistryProbe`] и [`Sleeper`],
6//! поэтому логика графа, ретраев и проверки версий тестируется без процессов и
7//! без сети.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use serde::Deserialize;
14
15use crate::error::{Error, Result};
16use crate::manifest::{min_version, parse_manifest_from_text};
17use crate::process::CommandRunner;
18
19/// Аргументы `cargo metadata` для построения графа воркспейса.
20pub fn metadata_args() -> Vec<String> {
21    vec![
22        "metadata".to_string(),
23        "--no-deps".to_string(),
24        "--format-version".to_string(),
25        "1".to_string(),
26    ]
27}
28
29/// Сырые данные `cargo metadata --no-deps --format-version 1`.
30#[derive(Debug, Clone, Deserialize)]
31pub struct Metadata {
32    /// Пакеты воркспейса.
33    pub packages: Vec<MetadataPackage>,
34    /// Идентификаторы членов воркспейса.
35    pub workspace_members: Vec<String>,
36    /// Корень воркспейса.
37    pub workspace_root: PathBuf,
38}
39
40/// Один пакет из `cargo metadata`.
41#[derive(Debug, Clone, Deserialize)]
42pub struct MetadataPackage {
43    /// Имя пакета.
44    pub name: String,
45    /// Версия (строка semver).
46    pub version: String,
47    /// Уникальный идентификатор пакета.
48    pub id: String,
49    /// Путь к `Cargo.toml`.
50    pub manifest_path: PathBuf,
51    /// `None` — публикуется по умолчанию, `Some([])` — `publish = false`.
52    pub publish: Option<Vec<String>>,
53    /// Описание из манифеста.
54    pub description: Option<String>,
55    /// Лицензия из манифеста.
56    pub license: Option<String>,
57    /// Репозиторий из манифеста.
58    pub repository: Option<String>,
59    /// Readme из манифеста.
60    pub readme: Option<String>,
61    /// Целевые артефакты пакета (`bin`/`lib`/…).
62    #[serde(default)]
63    pub targets: Vec<MetadataTarget>,
64    /// Зависимости пакета.
65    pub dependencies: Vec<MetadataDependency>,
66}
67
68/// Один целевой артефакт пакета из `cargo metadata`.
69#[derive(Debug, Clone, Deserialize)]
70pub struct MetadataTarget {
71    /// Имя цели (для бинарника — имя файла без расширения).
72    pub name: String,
73    /// Виды цели (`bin`, `lib`, `test`, …).
74    #[serde(default)]
75    pub kind: Vec<String>,
76}
77
78/// Одна зависимость из `cargo metadata`.
79#[derive(Debug, Clone, Deserialize)]
80pub struct MetadataDependency {
81    /// Имя пакета зависимости.
82    pub name: String,
83    /// Требование версии (semver-range).
84    pub req: String,
85    /// `None` — обычная зависимость, `"dev"`, `"build"`.
86    pub kind: Option<String>,
87    /// Прямой путь для локальной зависимости.
88    pub path: Option<PathBuf>,
89}
90
91/// Вид зависимости.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DepKind {
94    /// Обычная зависимость.
95    Normal,
96    /// Зависимость сборки.
97    Build,
98    /// Зависимость разработки (crates.io её игнорирует).
99    Dev,
100}
101
102impl DepKind {
103    fn from_metadata(kind: Option<&str>) -> Self {
104        match kind {
105            Some("dev") => DepKind::Dev,
106            Some("build") => DepKind::Build,
107            _ => DepKind::Normal,
108        }
109    }
110}
111
112/// Внутренняя (workspace) зависимость пакета.
113#[derive(Debug, Clone)]
114pub struct InternalDep {
115    /// Имя крейта-зависимости.
116    pub name: String,
117    /// Исходное требование версии.
118    pub req_raw: String,
119    /// Разобранное требование версии.
120    pub req: semver::VersionReq,
121    /// Вид зависимости.
122    pub kind: DepKind,
123}
124
125/// Пакет воркспейса с разрешёнными внутренними зависимостями.
126#[derive(Debug, Clone)]
127pub struct PackageInfo {
128    /// Имя крейта.
129    pub name: String,
130    /// Версия крейта.
131    pub version: semver::Version,
132    /// Путь к `Cargo.toml`.
133    pub manifest_path: PathBuf,
134    /// Публикуется ли крейт (`publish != false`).
135    pub publishable: bool,
136    /// Описание.
137    pub description: Option<String>,
138    /// Лицензия.
139    pub license: Option<String>,
140    /// Репозиторий.
141    pub repository: Option<String>,
142    /// Readme.
143    pub readme: Option<String>,
144    /// Внутренние зависимости (только члены воркспейса).
145    pub deps: Vec<InternalDep>,
146}
147
148/// Воркспейс и его пакеты.
149#[derive(Debug, Clone)]
150pub struct Workspace {
151    /// Корень воркспейса.
152    pub root: PathBuf,
153    /// Пакеты-члены воркспейса.
154    pub packages: Vec<PackageInfo>,
155    by_name: BTreeMap<String, usize>,
156}
157
158impl Workspace {
159    /// Строит модель воркспейса из данных `cargo metadata`.
160    pub fn from_metadata(metadata: Metadata) -> Result<Self> {
161        let members: BTreeSet<&str> = metadata
162            .workspace_members
163            .iter()
164            .map(String::as_str)
165            .collect();
166        let member_packages: Vec<&MetadataPackage> = metadata
167            .packages
168            .iter()
169            .filter(|package| members.contains(package.id.as_str()))
170            .collect();
171        let names: BTreeSet<String> = member_packages
172            .iter()
173            .map(|package| package.name.clone())
174            .collect();
175
176        let mut packages = Vec::with_capacity(member_packages.len());
177        for package in member_packages {
178            let version = semver::Version::parse(&package.version).map_err(|error| {
179                Error::Publish(format!("некорректная версия у `{}`: {error}", package.name))
180            })?;
181            let mut deps = Vec::new();
182            for dependency in &package.dependencies {
183                if !names.contains(&dependency.name) {
184                    continue;
185                }
186                let req = semver::VersionReq::parse(&dependency.req).map_err(|error| {
187                    Error::Publish(format!(
188                        "некорректное требование версии `{} = \"{}\"`: {error}",
189                        dependency.name, dependency.req
190                    ))
191                })?;
192                deps.push(InternalDep {
193                    name: dependency.name.clone(),
194                    req_raw: dependency.req.clone(),
195                    req,
196                    kind: DepKind::from_metadata(dependency.kind.as_deref()),
197                });
198            }
199            packages.push(PackageInfo {
200                name: package.name.clone(),
201                version,
202                manifest_path: package.manifest_path.clone(),
203                publishable: package.publish.as_ref().is_none_or(|list| !list.is_empty()),
204                description: package.description.clone(),
205                license: package.license.clone(),
206                repository: package.repository.clone(),
207                readme: package.readme.clone(),
208                deps,
209            });
210        }
211
212        let mut by_name = BTreeMap::new();
213        for (index, package) in packages.iter().enumerate() {
214            by_name.insert(package.name.clone(), index);
215        }
216
217        Ok(Self {
218            root: metadata.workspace_root,
219            packages,
220            by_name,
221        })
222    }
223
224    /// Пакет по имени.
225    pub fn package(&self, name: &str) -> Option<&PackageInfo> {
226        self.by_name.get(name).map(|index| &self.packages[*index])
227    }
228
229    /// Пакет по имени или ошибка, если такого члена воркспейса нет.
230    pub fn require(&self, name: &str) -> Result<&PackageInfo> {
231        self.package(name)
232            .ok_or_else(|| Error::Publish(format!("`{name}` не является членом воркспейса")))
233    }
234}
235
236/// План публикации.
237#[derive(Debug, Clone)]
238pub struct PublishPlan {
239    /// Крейты в топологическом порядке (зависимости — раньше зависимых).
240    pub order: Vec<String>,
241    /// Крейты, пропущенные из-за `publish = false`.
242    pub skipped: Vec<String>,
243}
244
245/// Загружает `cargo metadata` для каталога `cwd`.
246pub fn load_metadata(runner: &dyn CommandRunner, cwd: &Path) -> Result<Metadata> {
247    let output = runner.run("cargo", &metadata_args(), Some(cwd))?;
248    if !output.success() {
249        return Err(Error::CommandFailed {
250            program: "cargo metadata".to_string(),
251            code: output.status,
252            stderr: output.stderr.trim().to_string(),
253        });
254    }
255    Ok(serde_json::from_str(&output.stdout)?)
256}
257
258/// Имя пакета, бинарник которого сейчас выполняется, если он лежит в `target/`
259/// этого воркспейса.
260///
261/// Такой пакет исключается из pre-flight сборки ([`run_gates`]): иначе
262/// `cargo build --workspace` пытается перелинковать запущенный файл и на Windows
263/// падает с «failed to remove file …: access denied».
264pub fn running_package_exclude(metadata: &Metadata) -> Option<String> {
265    let exe = std::env::current_exe().ok()?.canonicalize().ok()?;
266    let root = metadata.workspace_root.canonicalize().ok()?;
267    if !exe.starts_with(root.join("target")) {
268        return None;
269    }
270    let stem = exe.file_stem()?.to_str()?;
271
272    let members: BTreeSet<&str> = metadata
273        .workspace_members
274        .iter()
275        .map(String::as_str)
276        .collect();
277    metadata
278        .packages
279        .iter()
280        .filter(|package| members.contains(package.id.as_str()))
281        .find(|package| {
282            package
283                .targets
284                .iter()
285                .any(|target| target.kind.iter().any(|kind| kind == "bin") && target.name == stem)
286        })
287        .map(|package| package.name.clone())
288}
289
290/// Строит план публикации: порядок и пропущенные крейты.
291///
292/// При `only = None` — весь воркспейс; при `Some(name)` — только указанный
293/// крейт (без цепочки). Возвращает ошибку, если публикуемый крейт зависит от
294/// непубликуемого, или если найдены циклы.
295pub fn plan(workspace: &Workspace, only: Option<&str>) -> Result<PublishPlan> {
296    let skipped: Vec<String> = workspace
297        .packages
298        .iter()
299        .filter(|package| !package.publishable)
300        .map(|package| package.name.clone())
301        .collect();
302    let non_publishable: BTreeSet<&str> = workspace
303        .packages
304        .iter()
305        .filter(|package| !package.publishable)
306        .map(|package| package.name.as_str())
307        .collect();
308
309    for package in workspace.packages.iter().filter(|p| p.publishable) {
310        for dep in package.deps.iter().filter(|d| d.kind != DepKind::Dev) {
311            if non_publishable.contains(dep.name.as_str()) {
312                return Err(Error::Publish(format!(
313                    "публикуемый крейт `{}` зависит от `{}`, у которого `publish = false`",
314                    package.name, dep.name
315                )));
316            }
317        }
318    }
319
320    let order = match only {
321        Some(name) => {
322            let package = workspace.require(name)?;
323            if !package.publishable {
324                return Err(Error::Publish(format!(
325                    "крейт `{name}` помечен `publish = false` и не может быть опубликован"
326                )));
327            }
328            vec![name.to_string()]
329        }
330        None => topo_sort(workspace)?,
331    };
332
333    Ok(PublishPlan { order, skipped })
334}
335
336/// Топологическая сортировка публикуемых крейтов.
337///
338/// Среди готовых к публикации узлов выбирается первый по алфавиту — порядок
339/// детерминирован и не зависит от порядка в `cargo metadata`.
340fn topo_sort(workspace: &Workspace) -> Result<Vec<String>> {
341    let selected: BTreeSet<&str> = workspace
342        .packages
343        .iter()
344        .filter(|package| package.publishable)
345        .map(|package| package.name.as_str())
346        .collect();
347
348    let mut indegree: BTreeMap<&str, usize> = selected.iter().map(|name| (*name, 0)).collect();
349    let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
350    for &name in &selected {
351        let package = workspace.require(name)?;
352        for dep in package.deps.iter().filter(|d| d.kind != DepKind::Dev) {
353            if selected.contains(dep.name.as_str()) {
354                if let Some(degree) = indegree.get_mut(name) {
355                    *degree += 1;
356                }
357                dependents.entry(dep.name.as_str()).or_default().push(name);
358            }
359        }
360    }
361
362    let mut ready: BTreeSet<&str> = indegree
363        .iter()
364        .filter(|(_, degree)| **degree == 0)
365        .map(|(name, _)| *name)
366        .collect();
367    let mut order = Vec::with_capacity(selected.len());
368    while let Some(name) = ready.iter().next().copied() {
369        ready.remove(name);
370        order.push(name.to_string());
371        if let Some(children) = dependents.get(name) {
372            for child in children {
373                if let Some(degree) = indegree.get_mut(child) {
374                    *degree -= 1;
375                    if *degree == 0 {
376                        ready.insert(child);
377                    }
378                }
379            }
380        }
381    }
382
383    if order.len() != selected.len() {
384        let cycle: Vec<String> = indegree
385            .iter()
386            .filter(|(_, degree)| **degree > 0)
387            .map(|(name, _)| (*name).to_string())
388            .collect();
389        return Err(Error::Publish(format!(
390            "цикл зависимостей среди крейтов: {}",
391            cycle.join(", ")
392        )));
393    }
394
395    Ok(order)
396}
397
398/// Проверка непубликуемого крейта, от которого зависит публикуемый — уже в
399/// [`plan`]; здесь остаются версии.
400///
401/// Проверяет, что `version` в `[dependencies]` совпадает с публикуемой версией
402/// соответствующего workspace-крейта.
403pub fn check_version_consistency(workspace: &Workspace, plan: &PublishPlan) -> Result<()> {
404    for name in &plan.order {
405        let package = workspace.require(name)?;
406        for dep in package.deps.iter().filter(|d| d.kind != DepKind::Dev) {
407            let Some(target) = workspace.package(&dep.name) else {
408                continue;
409            };
410            if !target.publishable {
411                continue;
412            }
413            let min = min_version(&dep.req);
414            let consistent =
415                dep.req.matches(&target.version) && min.as_ref() == Some(&target.version);
416            if !consistent {
417                return Err(Error::Publish(format!(
418                    "`{name}` зависит от `{} = \"{}\"`, а версия workspace — {}; версии должны совпадать",
419                    dep.name, dep.req_raw, target.version
420                )));
421            }
422        }
423    }
424    Ok(())
425}
426
427/// Предупреждения о неполных метаданных crates.io (не блокируют публикацию).
428pub fn metadata_warnings(workspace: &Workspace, plan: &PublishPlan) -> Vec<String> {
429    let mut warnings = Vec::new();
430    for name in &plan.order {
431        let Some(package) = workspace.package(name) else {
432            continue;
433        };
434        let mut missing = Vec::new();
435        if package.description.as_deref().is_none_or(str::is_empty) {
436            missing.push("description");
437        }
438        if package.license.as_deref().is_none_or(str::is_empty) {
439            missing.push("license");
440        }
441        if package.repository.as_deref().is_none_or(str::is_empty) {
442            missing.push("repository");
443        }
444        if package.readme.as_deref().is_none_or(str::is_empty) {
445            missing.push("readme");
446        }
447        if !missing.is_empty() {
448            warnings.push(format!("{name}: missing {}", missing.join(", ")));
449        }
450    }
451    warnings
452}
453
454/// Валидирует `[package.metadata.light]` каждого публикуемого крейта.
455pub fn validate_manifests(workspace: &Workspace, plan: &PublishPlan) -> Result<()> {
456    for name in &plan.order {
457        let package = workspace.require(name)?;
458        let text = std::fs::read_to_string(&package.manifest_path)?;
459        parse_manifest_from_text(&text)
460            .map_err(|error| Error::Publish(format!("{name}: {error}")))?;
461    }
462    Ok(())
463}
464
465/// Прогоняет `light check --strict` по `light-demo`, если он есть в воркспейсе.
466///
467/// Возвращает `Ok(Some(message))` при найденных проблемах.
468pub fn check_demo(workspace: &Workspace) -> Result<Option<String>> {
469    let Some(demo) = workspace.package("light-demo") else {
470        return Ok(None);
471    };
472    let Some(dir) = demo.manifest_path.parent() else {
473        return Ok(None);
474    };
475    let report = crate::project::analyze_project(dir)?;
476    let problems =
477        report.scan.errors.len() + report.missing.len() + report.unused.len() + report.issues.len();
478    if problems > 0 {
479        Ok(Some(format!(
480            "light check --strict: light-demo содержит {problems} проблем(ы)"
481        )))
482    } else {
483        Ok(None)
484    }
485}
486
487/// Результат одного гейта pre-flight.
488#[derive(Debug, Clone)]
489pub struct GateOutcome {
490    /// Имя гейта (`build`/`test`/`clippy`/`fmt`).
491    pub name: String,
492    /// Успешно ли.
493    pub ok: bool,
494    /// stderr при неудаче.
495    pub stderr: String,
496}
497
498/// Гейты pre-flight с опциональным исключением пакета.
499///
500/// `exclude` — имя пакета, который не нужно пересобирать (например, только что
501/// запущенный бинарник `light`); `cargo fmt` исключение не поддерживает.
502pub fn gate_commands(exclude: Option<&str>) -> Vec<(&'static str, Vec<String>)> {
503    fn args(base: &[&str], exclude: Option<&str>) -> Vec<String> {
504        let mut args: Vec<String> = base.iter().map(|arg| (*arg).to_string()).collect();
505        if let Some(name) = exclude {
506            args.push("--exclude".to_string());
507            args.push(name.to_string());
508        }
509        args
510    }
511
512    let mut clippy = args(&["clippy", "--workspace"], exclude);
513    clippy.push("--".to_string());
514    clippy.push("-D".to_string());
515    clippy.push("warnings".to_string());
516
517    vec![
518        ("build", args(&["build", "--workspace"], exclude)),
519        ("test", args(&["test", "--workspace"], exclude)),
520        ("clippy", clippy),
521        ("fmt", args(&["fmt", "--check", "--all"], None)),
522    ]
523}
524
525/// Прогоняет гейты `build`/`test`/`clippy`/`fmt`, останавливаясь на первом сбое.
526pub fn run_gates(
527    runner: &dyn CommandRunner,
528    root: &Path,
529    exclude: Option<&str>,
530) -> Result<Vec<GateOutcome>> {
531    let mut outcomes = Vec::new();
532    for (name, args) in gate_commands(exclude) {
533        let output = runner.run("cargo", &args, Some(root))?;
534        let ok = output.success();
535        outcomes.push(GateOutcome {
536            name: name.to_string(),
537            ok,
538            stderr: output.stderr.trim().to_string(),
539        });
540        if !ok {
541            break;
542        }
543    }
544    Ok(outcomes)
545}
546
547/// Аргументы `cargo package --list` для проверки упаковки в режиме dry-run.
548pub fn package_list_args(name: &str) -> Vec<String> {
549    vec![
550        "package".to_string(),
551        "--list".to_string(),
552        "-p".to_string(),
553        name.to_string(),
554        "--allow-dirty".to_string(),
555    ]
556}
557
558/// Проверяет упаковку крейта через `cargo package --list`.
559pub fn package_crate(runner: &dyn CommandRunner, root: &Path, name: &str) -> Result<()> {
560    let output = runner.run("cargo", &package_list_args(name), Some(root))?;
561    if !output.success() {
562        return Err(Error::CommandFailed {
563            program: format!("cargo package -p {name}"),
564            code: output.status,
565            stderr: output.stderr.trim().to_string(),
566        });
567    }
568    Ok(())
569}
570
571/// Параметры вызова `cargo publish`.
572#[derive(Debug, Clone, Default)]
573pub struct PublishInvocation {
574    /// Полная верификация (без `--no-verify`).
575    pub verify: bool,
576    /// Разрешить грязное рабочее дерево.
577    pub allow_dirty: bool,
578    /// Токен реестра.
579    pub token: Option<String>,
580}
581
582/// Аргументы `cargo publish` для крейта.
583pub fn publish_args(name: &str, invocation: &PublishInvocation) -> Vec<String> {
584    let mut args = vec!["publish".to_string(), "-p".to_string(), name.to_string()];
585    if !invocation.verify {
586        args.push("--no-verify".to_string());
587    }
588    if invocation.allow_dirty {
589        args.push("--allow-dirty".to_string());
590    }
591    if let Some(token) = &invocation.token {
592        args.push("--token".to_string());
593        args.push(token.clone());
594    }
595    args
596}
597
598/// Причина сбоя `cargo publish`.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum FailureKind {
601    /// Индекс crates.io ещё не увидел предыдущий крейт — стоит повторить.
602    IndexStale,
603    /// crates.io ограничил частоту публикаций — ретраи бесполезны.
604    RateLimited,
605    /// Прочая ошибка — ретраи не помогут.
606    Other,
607}
608
609/// Паттерны stderr, означающие «индекс ещё не обновился».
610///
611/// Список сознательно вынесен в константу и легко расширяется.
612pub const INDEX_STALE_PATTERNS: &[&str] = &[
613    "failed to select a version",
614    "no matching package named",
615    "could not be found in registry index",
616];
617
618/// Паттерны stderr, означающие превышение лимита публикаций crates.io.
619pub const RATE_LIMIT_PATTERNS: &[&str] = &[
620    "429 too many requests",
621    "too many new crates",
622    "rate limit reached",
623    "too many requests",
624];
625
626/// Классифицирует stderr `cargo publish` (exit code всегда 101, поэтому — по тексту).
627pub fn classify_publish_failure(stderr: &str) -> FailureKind {
628    let lower = stderr.to_lowercase();
629    if RATE_LIMIT_PATTERNS
630        .iter()
631        .any(|pattern| lower.contains(pattern))
632    {
633        FailureKind::RateLimited
634    } else if INDEX_STALE_PATTERNS
635        .iter()
636        .any(|pattern| lower.contains(pattern))
637    {
638        FailureKind::IndexStale
639    } else {
640        FailureKind::Other
641    }
642}
643
644/// Извлекает из stderr дату/время, после которого crates.io снова примет
645/// публикацию.
646///
647/// crates.io кладёт срок в текст ошибки (`retry after <timestamp>`,
648/// `try again after <timestamp>`); формат может отличаться, поэтому берём
649/// первый похожий на время токен после ключевой фразы.
650pub fn rate_limit_reset(stderr: &str) -> Option<String> {
651    const KEYS: &[&str] = &[
652        "try again after",
653        "retry after",
654        "available after",
655        "wait until",
656        "try again at",
657        "again after",
658    ];
659    let lower = stderr.to_lowercase();
660    for key in KEYS {
661        let Some(position) = lower.find(key) else {
662            continue;
663        };
664        let start = position + key.len();
665        if let Some(reset) = stderr.get(start..).and_then(reset_token) {
666            return Some(reset);
667        }
668    }
669    None
670}
671
672fn reset_token(rest: &str) -> Option<String> {
673    let begin = rest.find(|c: char| c.is_ascii_digit())?;
674    let tail = &rest[begin..];
675    let end = tail
676        .find(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | ')' | ']'))
677        .unwrap_or(tail.len());
678    let token = tail[..end].trim_end_matches(['.', ',', ';']);
679    if token.len() < 4 {
680        return None;
681    }
682    let looks_like_time = token.contains('-')
683        || token.contains(':')
684        || token.chars().any(|c| c.is_ascii_alphabetic());
685    looks_like_time.then(|| token.to_string())
686}
687
688/// Настройки ретраев на устаревание индекса.
689#[derive(Debug, Clone, Copy)]
690pub struct RetryConfig {
691    /// Сколько раз повторить попытку (не считая первой).
692    pub max_retries: u32,
693    /// Базовая задержка.
694    pub base: Duration,
695    /// Множитель экспоненциального backoff.
696    pub factor: u32,
697}
698
699impl Default for RetryConfig {
700    fn default() -> Self {
701        Self {
702            max_retries: 6,
703            base: Duration::from_secs(5),
704            factor: 2,
705        }
706    }
707}
708
709impl RetryConfig {
710    /// Читает настройки из окружения (`LIGHT_PUBLISH_RETRY_*`).
711    ///
712    /// - `LIGHT_PUBLISH_RETRY_MAX` — число повторов (по умолчанию 6);
713    /// - `LIGHT_PUBLISH_RETRY_BASE_MS` — базовая задержка в мс (по умолчанию 5000);
714    /// - `LIGHT_PUBLISH_RETRY_FACTOR` — множитель (по умолчанию 2).
715    pub fn from_env() -> Self {
716        let mut config = Self::default();
717        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_MAX")
718            && let Ok(parsed) = value.parse()
719        {
720            config.max_retries = parsed;
721        }
722        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_BASE_MS")
723            && let Ok(parsed) = value.parse::<u64>()
724        {
725            config.base = Duration::from_millis(parsed);
726        }
727        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_FACTOR")
728            && let Ok(parsed) = value.parse::<u32>()
729            && parsed >= 1
730        {
731            config.factor = parsed;
732        }
733        config
734    }
735
736    /// Задержка перед повтором номер `retry_index` (с нуля).
737    pub fn delay(&self, retry_index: u32) -> Duration {
738        let mut delay = self.base;
739        for _ in 0..retry_index {
740            delay = delay.saturating_mul(self.factor);
741        }
742        delay
743    }
744}
745
746/// Ожидание между ретраями (инжектируется, чтобы тесты не спали).
747pub trait Sleeper: Send + Sync {
748    /// Спит `duration`.
749    fn sleep(&self, duration: Duration);
750}
751
752/// Реальный [`Sleeper`] на `std::thread::sleep`.
753#[derive(Debug, Default, Clone, Copy)]
754pub struct SystemSleeper;
755
756impl Sleeper for SystemSleeper {
757    fn sleep(&self, duration: Duration) {
758        std::thread::sleep(duration);
759    }
760}
761
762/// Итог публикации одного крейта.
763#[derive(Debug, Clone)]
764pub struct PublishAttempt {
765    /// Успешно ли опубликован.
766    pub success: bool,
767    /// Число сделанных повторов.
768    pub retries: u32,
769    /// stderr последней неудачной попытки.
770    pub stderr: String,
771}
772
773/// Публикует крейт с ретраями на устаревание индекса crates.io.
774///
775/// Ретраи делаются только для [`FailureKind::IndexStale`]; прочие ошибки
776/// возвращаются сразу.
777pub fn publish_with_retries(
778    runner: &dyn CommandRunner,
779    sleeper: &dyn Sleeper,
780    root: &Path,
781    name: &str,
782    invocation: &PublishInvocation,
783    retry: &RetryConfig,
784) -> Result<PublishAttempt> {
785    let args = publish_args(name, invocation);
786    let mut retries = 0;
787    loop {
788        let output = runner.run("cargo", &args, Some(root))?;
789        if output.success() {
790            return Ok(PublishAttempt {
791                success: true,
792                retries,
793                stderr: String::new(),
794            });
795        }
796        let stderr = output.stderr;
797        let kind = classify_publish_failure(&stderr);
798        if kind != FailureKind::IndexStale || retries >= retry.max_retries {
799            return Ok(PublishAttempt {
800                success: false,
801                retries,
802                stderr,
803            });
804        }
805        sleeper.sleep(retry.delay(retries));
806        retries += 1;
807    }
808}
809
810/// Итог проверки crates.io API.
811#[derive(Debug, Clone, PartialEq, Eq)]
812pub enum ApiOutcome {
813    /// Реестр вернул список версий.
814    Versions(Vec<semver::Version>),
815    /// Крейт в реестре не найден.
816    NotFound,
817    /// Реестр недоступен (нет сети, ошибка API).
818    Unavailable,
819}
820
821/// Источник данных об опубликованных версиях crates.io.
822pub trait RegistryProbe: Send + Sync {
823    /// Возвращает список версий крейта на crates.io.
824    fn versions(&self, name: &str) -> ApiOutcome;
825}
826
827/// Реальный опрос crates.io API.
828#[derive(Debug, Default, Clone, Copy)]
829pub struct CratesIoProbe;
830
831impl RegistryProbe for CratesIoProbe {
832    fn versions(&self, name: &str) -> ApiOutcome {
833        #[cfg(feature = "network")]
834        {
835            crates_io_versions(name)
836        }
837        #[cfg(not(feature = "network"))]
838        {
839            let _ = name;
840            ApiOutcome::Unavailable
841        }
842    }
843}
844
845#[cfg(feature = "network")]
846fn crates_io_versions(name: &str) -> ApiOutcome {
847    #[derive(Deserialize)]
848    struct Response {
849        versions: Vec<VersionEntry>,
850    }
851    #[derive(Deserialize)]
852    struct VersionEntry {
853        num: String,
854    }
855
856    let client = match reqwest::blocking::Client::builder()
857        .timeout(Duration::from_secs(10))
858        .build()
859    {
860        Ok(client) => client,
861        Err(_) => return ApiOutcome::Unavailable,
862    };
863    let url = format!("https://crates.io/api/v1/crates/{name}");
864    let response = match client
865        .get(&url)
866        .header("User-Agent", "light-fw-cli (publish pre-flight)")
867        .send()
868    {
869        Ok(response) => response,
870        Err(_) => return ApiOutcome::Unavailable,
871    };
872    if response.status() == reqwest::StatusCode::NOT_FOUND {
873        return ApiOutcome::NotFound;
874    }
875    let response = match response.error_for_status() {
876        Ok(response) => response,
877        Err(_) => return ApiOutcome::Unavailable,
878    };
879    match response.json::<Response>() {
880        Ok(parsed) => ApiOutcome::Versions(
881            parsed
882                .versions
883                .iter()
884                .filter_map(|entry| semver::Version::parse(&entry.num).ok())
885                .collect(),
886        ),
887        Err(_) => ApiOutcome::Unavailable,
888    }
889}
890
891/// Локальные git-теги (используются как fallback к crates.io API).
892pub fn git_tags(runner: &dyn CommandRunner, root: &Path) -> Option<BTreeSet<String>> {
893    let output = runner
894        .run(
895            "git",
896            &["tag".to_string(), "--list".to_string()],
897            Some(root),
898        )
899        .ok()?;
900    if !output.success() {
901        return None;
902    }
903    Some(
904        output
905            .stdout
906            .lines()
907            .map(str::trim)
908            .filter(|line| !line.is_empty())
909            .map(str::to_string)
910            .collect(),
911    )
912}
913
914/// Результат проверки «версия уже опубликована».
915#[derive(Debug, Clone)]
916pub struct PublishedCheck {
917    /// Уже опубликована ли версия.
918    pub published: bool,
919    /// Предупреждения (расхождение источников, недоступность API).
920    pub warnings: Vec<String>,
921}
922
923/// Сверяет версию с crates.io API и git-тегами `v<version>`.
924///
925/// Оба источника «уже есть» → публикация запрещена; расхождение — warning и
926/// продолжение; недоступность API — fallback на git-теги с warning.
927pub fn check_already_published(
928    probe: &dyn RegistryProbe,
929    git_tags: Option<&BTreeSet<String>>,
930    name: &str,
931    version: &semver::Version,
932) -> PublishedCheck {
933    let api = match probe.versions(name) {
934        ApiOutcome::Versions(versions) => Some(versions.contains(version)),
935        ApiOutcome::NotFound => Some(false),
936        ApiOutcome::Unavailable => None,
937    };
938    let git = git_tags.map(|tags| tags.contains(&format!("v{version}")));
939
940    let mut warnings = Vec::new();
941    let published = match (api, git) {
942        (Some(true), Some(true)) => true,
943        (Some(false), Some(false)) => false,
944        (Some(api), Some(git)) if api != git => {
945            warnings.push(format!(
946                "crates.io API и git-теги расходятся для {name} v{version}; продолжаю"
947            ));
948            false
949        }
950        (None, Some(true)) => {
951            warnings.push(format!(
952                "crates.io API unreachable, falling back to git tags for {name}"
953            ));
954            true
955        }
956        (None, Some(false)) => {
957            warnings.push(format!(
958                "crates.io API unreachable, falling back to git tags for {name}"
959            ));
960            false
961        }
962        (Some(true), None) => {
963            warnings.push(format!(
964                "git-теги недоступны, доверяю crates.io API для {name}"
965            ));
966            true
967        }
968        (Some(false), None) => {
969            warnings.push(format!(
970                "git-теги недоступны, доверяю crates.io API для {name}"
971            ));
972            false
973        }
974        (None, None) => {
975            warnings.push(format!(
976                "crates.io API недоступен и git-теги недоступны для {name}; проверить не удалось"
977            ));
978            false
979        }
980        (Some(_), Some(_)) => unreachable!("случай равенства обработан выше"),
981    };
982
983    PublishedCheck {
984        published,
985        warnings,
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992    use crate::process::CommandOutput;
993    use std::collections::VecDeque;
994    use std::sync::Mutex;
995
996    fn package(name: &str, publishable: bool, deps: &[(&str, DepKind, &str)]) -> PackageInfo {
997        PackageInfo {
998            name: name.to_string(),
999            version: semver::Version::new(0, 2, 0),
1000            manifest_path: PathBuf::from(format!("{name}/Cargo.toml")),
1001            publishable,
1002            description: None,
1003            license: None,
1004            repository: None,
1005            readme: None,
1006            deps: deps
1007                .iter()
1008                .map(|(dep_name, kind, req)| InternalDep {
1009                    name: (*dep_name).to_string(),
1010                    req_raw: (*req).to_string(),
1011                    req: semver::VersionReq::parse(req).expect("req"),
1012                    kind: *kind,
1013                })
1014                .collect(),
1015        }
1016    }
1017
1018    fn workspace(packages: Vec<PackageInfo>) -> Workspace {
1019        let mut by_name = BTreeMap::new();
1020        for (index, package) in packages.iter().enumerate() {
1021            by_name.insert(package.name.clone(), index);
1022        }
1023        Workspace {
1024            root: PathBuf::from("."),
1025            packages,
1026            by_name,
1027        }
1028    }
1029
1030    fn ok_output() -> CommandOutput {
1031        CommandOutput {
1032            status: 0,
1033            stdout: String::new(),
1034            stderr: String::new(),
1035        }
1036    }
1037
1038    fn fail_output(stderr: &str) -> CommandOutput {
1039        CommandOutput {
1040            status: 101,
1041            stdout: String::new(),
1042            stderr: stderr.to_string(),
1043        }
1044    }
1045
1046    struct ScriptedRunner {
1047        outputs: Mutex<VecDeque<CommandOutput>>,
1048        calls: Mutex<Vec<Vec<String>>>,
1049    }
1050
1051    impl ScriptedRunner {
1052        fn new(outputs: Vec<CommandOutput>) -> Self {
1053            Self {
1054                outputs: Mutex::new(outputs.into()),
1055                calls: Mutex::new(Vec::new()),
1056            }
1057        }
1058        fn call_count(&self) -> usize {
1059            self.calls.lock().expect("calls").len()
1060        }
1061    }
1062
1063    impl CommandRunner for ScriptedRunner {
1064        fn run(
1065            &self,
1066            _program: &str,
1067            args: &[String],
1068            _cwd: Option<&Path>,
1069        ) -> Result<CommandOutput> {
1070            self.calls.lock().expect("calls").push(args.to_vec());
1071            Ok(self
1072                .outputs
1073                .lock()
1074                .expect("outputs")
1075                .pop_front()
1076                .expect("scripted output"))
1077        }
1078    }
1079
1080    struct RecordingSleeper {
1081        sleeps: Mutex<Vec<Duration>>,
1082    }
1083
1084    impl RecordingSleeper {
1085        fn new() -> Self {
1086            Self {
1087                sleeps: Mutex::new(Vec::new()),
1088            }
1089        }
1090        fn sleeps(&self) -> Vec<Duration> {
1091            self.sleeps.lock().expect("sleeps").clone()
1092        }
1093    }
1094
1095    impl Sleeper for RecordingSleeper {
1096        fn sleep(&self, duration: Duration) {
1097            self.sleeps.lock().expect("sleeps").push(duration);
1098        }
1099    }
1100
1101    struct MockProbe {
1102        outcome: ApiOutcome,
1103    }
1104
1105    impl RegistryProbe for MockProbe {
1106        fn versions(&self, _name: &str) -> ApiOutcome {
1107            self.outcome.clone()
1108        }
1109    }
1110
1111    #[test]
1112    fn topological_order_puts_dependencies_first() {
1113        let ws = workspace(vec![
1114            package(
1115                "light-fw-cli",
1116                true,
1117                &[("light-fw-cli-core", DepKind::Normal, "^0.2.0")],
1118            ),
1119            package("light-fw-core", true, &[]),
1120            package(
1121                "light-fw-cli-core",
1122                true,
1123                &[("light-fw-core", DepKind::Normal, "^0.2.0")],
1124            ),
1125        ]);
1126        let plan = plan(&ws, None).expect("plan");
1127        assert_eq!(
1128            plan.order,
1129            vec!["light-fw-core", "light-fw-cli-core", "light-fw-cli"]
1130        );
1131    }
1132
1133    #[test]
1134    fn dev_dependencies_do_not_create_edges() {
1135        let ws = workspace(vec![
1136            package("a", true, &[]),
1137            package("b", true, &[("a", DepKind::Dev, "^0.2.0")]),
1138        ]);
1139        let plan = plan(&ws, None).expect("plan");
1140        assert_eq!(plan.order, vec!["a", "b"]);
1141    }
1142
1143    #[test]
1144    fn publish_false_is_skipped() {
1145        let ws = workspace(vec![
1146            package("light-fw-core", true, &[]),
1147            package(
1148                "light-demo",
1149                false,
1150                &[("light-fw-core", DepKind::Normal, "^0.2.0")],
1151            ),
1152        ]);
1153        let plan = plan(&ws, None).expect("plan");
1154        assert_eq!(plan.order, vec!["light-fw-core"]);
1155        assert_eq!(plan.skipped, vec!["light-demo"]);
1156    }
1157
1158    #[test]
1159    fn publishable_depending_on_unpublished_is_error() {
1160        let ws = workspace(vec![
1161            package("app", true, &[("dep", DepKind::Normal, "^0.2.0")]),
1162            package("dep", false, &[]),
1163        ]);
1164        let error = plan(&ws, None).expect_err("должна быть ошибка");
1165        assert!(
1166            error.to_string().contains("publish = false"),
1167            "неожиданная ошибка: {error}"
1168        );
1169    }
1170
1171    #[test]
1172    fn dependency_cycle_is_error() {
1173        let ws = workspace(vec![
1174            package("a", true, &[("b", DepKind::Normal, "^0.2.0")]),
1175            package("b", true, &[("a", DepKind::Normal, "^0.2.0")]),
1176        ]);
1177        let error = plan(&ws, None).expect_err("цикл");
1178        assert!(error.to_string().contains("цикл"), "{error}");
1179    }
1180
1181    #[test]
1182    fn single_crate_selects_one() {
1183        let ws = workspace(vec![
1184            package("a", true, &[]),
1185            package("b", true, &[("a", DepKind::Normal, "^0.2.0")]),
1186        ]);
1187        let plan = plan(&ws, Some("b")).expect("plan");
1188        assert_eq!(plan.order, vec!["b"]);
1189    }
1190
1191    #[test]
1192    fn unknown_single_crate_is_error() {
1193        let ws = workspace(vec![package("a", true, &[])]);
1194        let error = plan(&ws, Some("missing")).expect_err("не найден");
1195        assert!(error.to_string().contains("не является членом"), "{error}");
1196    }
1197
1198    #[test]
1199    fn single_unpublished_crate_is_error() {
1200        let ws = workspace(vec![package("demo", false, &[])]);
1201        let error = plan(&ws, Some("demo")).expect_err("publish = false");
1202        assert!(error.to_string().contains("publish = false"), "{error}");
1203    }
1204
1205    #[test]
1206    fn version_consistency_detects_mismatch() {
1207        let ws = workspace(vec![
1208            package("core", true, &[]),
1209            package("comp", true, &[("core", DepKind::Normal, "^0.1.0")]),
1210        ]);
1211        let plan = plan(&ws, None).expect("plan");
1212        let error = check_version_consistency(&ws, &plan).expect_err("mismatch");
1213        assert!(error.to_string().contains("должны совпадать"), "{error}");
1214    }
1215
1216    #[test]
1217    fn version_consistency_accepts_matching_dependency() {
1218        let ws = workspace(vec![
1219            package("core", true, &[]),
1220            package("comp", true, &[("core", DepKind::Normal, "^0.2.0")]),
1221        ]);
1222        let plan = plan(&ws, None).expect("plan");
1223        assert!(check_version_consistency(&ws, &plan).is_ok());
1224    }
1225
1226    #[test]
1227    fn metadata_warnings_list_missing_fields() {
1228        let ws = workspace(vec![package("bare", true, &[]), {
1229            let mut p = package("full", true, &[]);
1230            p.description = Some("d".into());
1231            p.license = Some("MIT".into());
1232            p.repository = Some("https://example.com".into());
1233            p.readme = Some("README.md".into());
1234            p
1235        }]);
1236        let plan = plan(&ws, None).expect("plan");
1237        let warnings = metadata_warnings(&ws, &plan);
1238        assert_eq!(warnings.len(), 1);
1239        assert!(warnings[0].starts_with("bare: missing "), "{warnings:?}");
1240        assert!(warnings[0].contains("readme"), "{warnings:?}");
1241    }
1242
1243    #[test]
1244    fn classify_detects_index_staleness() {
1245        assert_eq!(
1246            classify_publish_failure("error: failed to select a version for the requirement"),
1247            FailureKind::IndexStale
1248        );
1249        assert_eq!(
1250            classify_publish_failure("no matching package named `light-fw-core` found"),
1251            FailureKind::IndexStale
1252        );
1253        assert_eq!(
1254            classify_publish_failure("error: api token not provided"),
1255            FailureKind::Other
1256        );
1257    }
1258
1259    #[test]
1260    fn classify_detects_rate_limit() {
1261        assert_eq!(
1262            classify_publish_failure(
1263                "the remote server responded with an error (status 429 Too Many Requests)"
1264            ),
1265            FailureKind::RateLimited
1266        );
1267        assert_eq!(
1268            classify_publish_failure(
1269                "You have hit the limit for the number of too many new crates"
1270            ),
1271            FailureKind::RateLimited
1272        );
1273    }
1274
1275    #[test]
1276    fn rate_limit_reset_extracts_timestamp() {
1277        let stderr = "error: the remote server responded with an error (status 429 Too Many \
1278                      Requests): too many new crates. Please try again after \
1279                      2026-10-20T12:34:56Z.";
1280        assert_eq!(
1281            rate_limit_reset(stderr).as_deref(),
1282            Some("2026-10-20T12:34:56Z")
1283        );
1284    }
1285
1286    #[test]
1287    fn rate_limit_reset_is_optional() {
1288        assert_eq!(rate_limit_reset("status 429 Too Many Requests"), None);
1289        assert_eq!(rate_limit_reset("api token not provided"), None);
1290    }
1291
1292    #[test]
1293    fn publish_does_not_retry_rate_limit() {
1294        let runner = ScriptedRunner::new(vec![fail_output(
1295            "error: status 429 Too Many Requests: too many new crates",
1296        )]);
1297        let sleeper = RecordingSleeper::new();
1298        let attempt = publish_with_retries(
1299            &runner,
1300            &sleeper,
1301            Path::new("."),
1302            "light",
1303            &PublishInvocation::default(),
1304            &RetryConfig::default(),
1305        )
1306        .expect("publish");
1307        assert!(!attempt.success);
1308        assert_eq!(attempt.retries, 0);
1309        assert!(sleeper.sleeps().is_empty());
1310        assert_eq!(runner.call_count(), 1);
1311        assert_eq!(
1312            classify_publish_failure(&attempt.stderr),
1313            FailureKind::RateLimited
1314        );
1315    }
1316
1317    #[test]
1318    fn retry_backoff_is_exponential() {
1319        let config = RetryConfig {
1320            max_retries: 4,
1321            base: Duration::from_secs(5),
1322            factor: 2,
1323        };
1324        assert_eq!(config.delay(0), Duration::from_secs(5));
1325        assert_eq!(config.delay(1), Duration::from_secs(10));
1326        assert_eq!(config.delay(2), Duration::from_secs(20));
1327        assert_eq!(config.delay(3), Duration::from_secs(40));
1328    }
1329
1330    #[test]
1331    fn publish_retries_index_staleness_then_succeeds() {
1332        let runner = ScriptedRunner::new(vec![
1333            fail_output("no matching package named `light-fw-core` found"),
1334            ok_output(),
1335        ]);
1336        let sleeper = RecordingSleeper::new();
1337        let config = RetryConfig {
1338            max_retries: 3,
1339            base: Duration::from_secs(5),
1340            factor: 2,
1341        };
1342        let attempt = publish_with_retries(
1343            &runner,
1344            &sleeper,
1345            Path::new("."),
1346            "light",
1347            &PublishInvocation::default(),
1348            &config,
1349        )
1350        .expect("publish");
1351        assert!(attempt.success);
1352        assert_eq!(attempt.retries, 1);
1353        assert_eq!(sleeper.sleeps(), vec![Duration::from_secs(5)]);
1354        assert_eq!(runner.call_count(), 2);
1355    }
1356
1357    #[test]
1358    fn publish_stops_immediately_on_other_error() {
1359        let runner = ScriptedRunner::new(vec![fail_output("error: api token not provided")]);
1360        let sleeper = RecordingSleeper::new();
1361        let attempt = publish_with_retries(
1362            &runner,
1363            &sleeper,
1364            Path::new("."),
1365            "light",
1366            &PublishInvocation::default(),
1367            &RetryConfig::default(),
1368        )
1369        .expect("publish");
1370        assert!(!attempt.success);
1371        assert_eq!(attempt.retries, 0);
1372        assert!(sleeper.sleeps().is_empty());
1373        assert_eq!(runner.call_count(), 1);
1374    }
1375
1376    #[test]
1377    fn publish_exhausts_retries() {
1378        let runner = ScriptedRunner::new(vec![
1379            fail_output("failed to select a version"),
1380            fail_output("failed to select a version"),
1381        ]);
1382        let sleeper = RecordingSleeper::new();
1383        let config = RetryConfig {
1384            max_retries: 1,
1385            base: Duration::from_secs(5),
1386            factor: 2,
1387        };
1388        let attempt = publish_with_retries(
1389            &runner,
1390            &sleeper,
1391            Path::new("."),
1392            "light",
1393            &PublishInvocation::default(),
1394            &config,
1395        )
1396        .expect("publish");
1397        assert!(!attempt.success);
1398        assert_eq!(attempt.retries, 1);
1399        assert_eq!(sleeper.sleeps(), vec![Duration::from_secs(5)]);
1400        assert_eq!(runner.call_count(), 2);
1401    }
1402
1403    #[test]
1404    fn publish_args_shape() {
1405        let invocation = PublishInvocation {
1406            verify: false,
1407            allow_dirty: true,
1408            token: Some("secret".into()),
1409        };
1410        assert_eq!(
1411            publish_args("light", &invocation),
1412            vec![
1413                "publish",
1414                "-p",
1415                "light",
1416                "--no-verify",
1417                "--allow-dirty",
1418                "--token",
1419                "secret"
1420            ]
1421        );
1422        let verifying = PublishInvocation {
1423            verify: true,
1424            ..PublishInvocation::default()
1425        };
1426        assert_eq!(
1427            publish_args("light", &verifying),
1428            vec!["publish", "-p", "light"]
1429        );
1430    }
1431
1432    #[test]
1433    fn package_list_args_shape() {
1434        assert_eq!(
1435            package_list_args("light-fw-core"),
1436            vec!["package", "--list", "-p", "light-fw-core", "--allow-dirty"]
1437        );
1438    }
1439
1440    #[test]
1441    fn gate_commands_apply_exclude_to_build_test_clippy() {
1442        let commands = gate_commands(Some("light-fw-cli"));
1443        let names: Vec<&str> = commands.iter().map(|(name, _)| *name).collect();
1444        assert_eq!(names, vec!["build", "test", "clippy", "fmt"]);
1445        for (name, args) in &commands {
1446            let has_exclude = args.iter().any(|arg| arg == "--exclude");
1447            if *name == "fmt" {
1448                assert!(!has_exclude, "cargo fmt не поддерживает --exclude");
1449            } else {
1450                assert!(has_exclude, "{name} должен исключать запущенный крейт");
1451            }
1452            if *name == "clippy" {
1453                assert_eq!(args.last().map(String::as_str), Some("warnings"));
1454            }
1455        }
1456    }
1457
1458    #[test]
1459    fn gate_commands_without_exclude_are_plain() {
1460        let commands = gate_commands(None);
1461        assert!(
1462            commands
1463                .iter()
1464                .all(|(_, args)| !args.iter().any(|arg| arg == "--exclude"))
1465        );
1466    }
1467
1468    #[test]
1469    fn already_published_when_both_sources_agree() {
1470        let probe = MockProbe {
1471            outcome: ApiOutcome::Versions(vec![semver::Version::new(0, 2, 0)]),
1472        };
1473        let mut tags = BTreeSet::new();
1474        tags.insert("v0.2.0".to_string());
1475        let check =
1476            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1477        assert!(check.published);
1478        assert!(check.warnings.is_empty());
1479    }
1480
1481    #[test]
1482    fn already_published_disagreement_warns_and_continues() {
1483        let probe = MockProbe {
1484            outcome: ApiOutcome::Versions(vec![semver::Version::new(0, 2, 0)]),
1485        };
1486        let tags = BTreeSet::new();
1487        let check =
1488            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1489        assert!(!check.published);
1490        assert_eq!(check.warnings.len(), 1);
1491        assert!(
1492            check.warnings[0].contains("расходятся"),
1493            "{:?}",
1494            check.warnings
1495        );
1496    }
1497
1498    #[test]
1499    fn api_unreachable_falls_back_to_git_tags() {
1500        let probe = MockProbe {
1501            outcome: ApiOutcome::Unavailable,
1502        };
1503        let mut tags = BTreeSet::new();
1504        tags.insert("v0.2.0".to_string());
1505        let check =
1506            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1507        assert!(check.published);
1508        assert!(
1509            check.warnings[0].contains("falling back to git tags"),
1510            "{:?}",
1511            check.warnings
1512        );
1513    }
1514
1515    #[test]
1516    fn not_found_on_registry_is_not_published() {
1517        let probe = MockProbe {
1518            outcome: ApiOutcome::NotFound,
1519        };
1520        let tags = BTreeSet::new();
1521        let check =
1522            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1523        assert!(!check.published);
1524        assert!(check.warnings.is_empty());
1525    }
1526}