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    /// Версия уже есть в git-индексе crates.io, хотя API её ещё не показывает.
602    AlreadyPublished,
603    /// Индекс crates.io ещё не увидел предыдущий крейт — стоит повторить.
604    IndexStale,
605    /// crates.io ограничил частоту публикаций — ретраи бесполезны.
606    RateLimited,
607    /// Прочая ошибка — ретраи не помогут.
608    Other,
609}
610
611/// Паттерны stdout/stderr, означающие, что версия уже есть в git-индексе.
612///
613/// crates.io API может отставать от индекса, который использует `cargo`;
614/// в этом случае `cargo publish` завершается ошибкой, хотя публиковать уже
615/// нечего.
616pub const ALREADY_PUBLISHED_PATTERNS: &[&str] = &[
617    "already exists on crates.io index",
618    "already uploaded",
619    "already been published",
620    "already published",
621];
622
623/// Паттерны stderr, означающие «индекс ещё не обновился».
624///
625/// Список сознательно вынесен в константу и легко расширяется.
626pub const INDEX_STALE_PATTERNS: &[&str] = &[
627    "failed to select a version",
628    "no matching package named",
629    "could not be found in registry index",
630];
631
632/// Паттерны stderr, означающие превышение лимита публикаций crates.io.
633pub const RATE_LIMIT_PATTERNS: &[&str] = &[
634    "429 too many requests",
635    "too many new crates",
636    "rate limit reached",
637    "too many requests",
638];
639
640/// Классифицирует вывод `cargo publish` (exit code всегда 101, поэтому — по тексту).
641pub fn classify_publish_failure(stderr: &str) -> FailureKind {
642    let lower = stderr.to_lowercase();
643    if RATE_LIMIT_PATTERNS
644        .iter()
645        .any(|pattern| lower.contains(pattern))
646    {
647        FailureKind::RateLimited
648    } else if ALREADY_PUBLISHED_PATTERNS
649        .iter()
650        .any(|pattern| lower.contains(pattern))
651    {
652        FailureKind::AlreadyPublished
653    } else if INDEX_STALE_PATTERNS
654        .iter()
655        .any(|pattern| lower.contains(pattern))
656    {
657        FailureKind::IndexStale
658    } else {
659        FailureKind::Other
660    }
661}
662
663/// Извлекает из stderr дату/время, после которого crates.io снова примет
664/// публикацию.
665///
666/// crates.io кладёт срок в текст ошибки (`retry after <timestamp>`,
667/// `try again after <timestamp>`); формат может отличаться, поэтому берём
668/// первый похожий на время токен после ключевой фразы.
669pub fn rate_limit_reset(stderr: &str) -> Option<String> {
670    const KEYS: &[&str] = &[
671        "try again after",
672        "retry after",
673        "available after",
674        "wait until",
675        "try again at",
676        "again after",
677    ];
678    let lower = stderr.to_lowercase();
679    for key in KEYS {
680        let Some(position) = lower.find(key) else {
681            continue;
682        };
683        let start = position + key.len();
684        if let Some(reset) = stderr.get(start..).and_then(reset_token) {
685            return Some(reset);
686        }
687    }
688    None
689}
690
691fn reset_token(rest: &str) -> Option<String> {
692    let begin = rest.find(|c: char| c.is_ascii_digit())?;
693    let tail = &rest[begin..];
694    let end = tail
695        .find(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | ')' | ']'))
696        .unwrap_or(tail.len());
697    let token = tail[..end].trim_end_matches(['.', ',', ';']);
698    if token.len() < 4 {
699        return None;
700    }
701    let looks_like_time = token.contains('-')
702        || token.contains(':')
703        || token.chars().any(|c| c.is_ascii_alphabetic());
704    looks_like_time.then(|| token.to_string())
705}
706
707/// Настройки ретраев на устаревание индекса.
708#[derive(Debug, Clone, Copy)]
709pub struct RetryConfig {
710    /// Сколько раз повторить попытку (не считая первой).
711    pub max_retries: u32,
712    /// Базовая задержка.
713    pub base: Duration,
714    /// Множитель экспоненциального backoff.
715    pub factor: u32,
716}
717
718impl Default for RetryConfig {
719    fn default() -> Self {
720        Self {
721            max_retries: 6,
722            base: Duration::from_secs(5),
723            factor: 2,
724        }
725    }
726}
727
728impl RetryConfig {
729    /// Читает настройки из окружения (`LIGHT_PUBLISH_RETRY_*`).
730    ///
731    /// - `LIGHT_PUBLISH_RETRY_MAX` — число повторов (по умолчанию 6);
732    /// - `LIGHT_PUBLISH_RETRY_BASE_MS` — базовая задержка в мс (по умолчанию 5000);
733    /// - `LIGHT_PUBLISH_RETRY_FACTOR` — множитель (по умолчанию 2).
734    pub fn from_env() -> Self {
735        let mut config = Self::default();
736        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_MAX")
737            && let Ok(parsed) = value.parse()
738        {
739            config.max_retries = parsed;
740        }
741        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_BASE_MS")
742            && let Ok(parsed) = value.parse::<u64>()
743        {
744            config.base = Duration::from_millis(parsed);
745        }
746        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_FACTOR")
747            && let Ok(parsed) = value.parse::<u32>()
748            && parsed >= 1
749        {
750            config.factor = parsed;
751        }
752        config
753    }
754
755    /// Задержка перед повтором номер `retry_index` (с нуля).
756    pub fn delay(&self, retry_index: u32) -> Duration {
757        let mut delay = self.base;
758        for _ in 0..retry_index {
759            delay = delay.saturating_mul(self.factor);
760        }
761        delay
762    }
763}
764
765/// Ожидание между ретраями (инжектируется, чтобы тесты не спали).
766pub trait Sleeper: Send + Sync {
767    /// Спит `duration`.
768    fn sleep(&self, duration: Duration);
769}
770
771/// Реальный [`Sleeper`] на `std::thread::sleep`.
772#[derive(Debug, Default, Clone, Copy)]
773pub struct SystemSleeper;
774
775impl Sleeper for SystemSleeper {
776    fn sleep(&self, duration: Duration) {
777        std::thread::sleep(duration);
778    }
779}
780
781/// Итог публикации одного крейта.
782#[derive(Debug, Clone)]
783pub struct PublishAttempt {
784    /// Успешно ли опубликован в этот раз.
785    pub success: bool,
786    /// Версия уже была в git-индексе crates.io, публикация пропущена.
787    pub already_published: bool,
788    /// Число сделанных повторов.
789    pub retries: u32,
790    /// stderr последней неудачной попытки.
791    pub stderr: String,
792}
793
794/// Публикует крейт с ретраями на устаревание индекса crates.io.
795///
796/// Ретраи делаются только для [`FailureKind::IndexStale`]; прочие ошибки
797/// возвращаются сразу. Если версия уже есть в git-индексе, попытка помечается
798/// [`PublishAttempt::already_published`] и не считается сбоем.
799pub fn publish_with_retries(
800    runner: &dyn CommandRunner,
801    sleeper: &dyn Sleeper,
802    root: &Path,
803    name: &str,
804    invocation: &PublishInvocation,
805    retry: &RetryConfig,
806) -> Result<PublishAttempt> {
807    let args = publish_args(name, invocation);
808    let mut retries = 0;
809    loop {
810        let output = runner.run("cargo", &args, Some(root))?;
811        if output.success() {
812            return Ok(PublishAttempt {
813                success: true,
814                already_published: false,
815                retries,
816                stderr: String::new(),
817            });
818        }
819        let diagnostics = format!("{}\n{}", output.stdout, output.stderr);
820        let kind = classify_publish_failure(&diagnostics);
821        if kind == FailureKind::AlreadyPublished {
822            return Ok(PublishAttempt {
823                success: false,
824                already_published: true,
825                retries,
826                stderr: output.stderr,
827            });
828        }
829        if kind != FailureKind::IndexStale || retries >= retry.max_retries {
830            return Ok(PublishAttempt {
831                success: false,
832                already_published: false,
833                retries,
834                stderr: output.stderr,
835            });
836        }
837        sleeper.sleep(retry.delay(retries));
838        retries += 1;
839    }
840}
841
842/// Итог проверки crates.io API.
843#[derive(Debug, Clone, PartialEq, Eq)]
844pub enum ApiOutcome {
845    /// Реестр вернул список версий.
846    Versions(Vec<semver::Version>),
847    /// Крейт в реестре не найден.
848    NotFound,
849    /// Реестр недоступен (нет сети, ошибка API).
850    Unavailable,
851}
852
853/// Источник данных об опубликованных версиях crates.io.
854pub trait RegistryProbe: Send + Sync {
855    /// Возвращает список версий крейта на crates.io.
856    fn versions(&self, name: &str) -> ApiOutcome;
857}
858
859/// Реальный опрос crates.io API.
860#[derive(Debug, Default, Clone, Copy)]
861pub struct CratesIoProbe;
862
863impl RegistryProbe for CratesIoProbe {
864    fn versions(&self, name: &str) -> ApiOutcome {
865        #[cfg(feature = "network")]
866        {
867            crates_io_versions(name)
868        }
869        #[cfg(not(feature = "network"))]
870        {
871            let _ = name;
872            ApiOutcome::Unavailable
873        }
874    }
875}
876
877#[cfg(feature = "network")]
878fn crates_io_versions(name: &str) -> ApiOutcome {
879    #[derive(Deserialize)]
880    struct Response {
881        versions: Vec<VersionEntry>,
882    }
883    #[derive(Deserialize)]
884    struct VersionEntry {
885        num: String,
886    }
887
888    let client = match reqwest::blocking::Client::builder()
889        .timeout(Duration::from_secs(10))
890        .build()
891    {
892        Ok(client) => client,
893        Err(_) => return ApiOutcome::Unavailable,
894    };
895    let url = format!("https://crates.io/api/v1/crates/{name}");
896    let response = match client
897        .get(&url)
898        .header("User-Agent", "light-fw-cli (publish pre-flight)")
899        .send()
900    {
901        Ok(response) => response,
902        Err(_) => return ApiOutcome::Unavailable,
903    };
904    if response.status() == reqwest::StatusCode::NOT_FOUND {
905        return ApiOutcome::NotFound;
906    }
907    let response = match response.error_for_status() {
908        Ok(response) => response,
909        Err(_) => return ApiOutcome::Unavailable,
910    };
911    match response.json::<Response>() {
912        Ok(parsed) => ApiOutcome::Versions(
913            parsed
914                .versions
915                .iter()
916                .filter_map(|entry| semver::Version::parse(&entry.num).ok())
917                .collect(),
918        ),
919        Err(_) => ApiOutcome::Unavailable,
920    }
921}
922
923/// Локальные git-теги (используются как fallback к crates.io API).
924pub fn git_tags(runner: &dyn CommandRunner, root: &Path) -> Option<BTreeSet<String>> {
925    let output = runner
926        .run(
927            "git",
928            &["tag".to_string(), "--list".to_string()],
929            Some(root),
930        )
931        .ok()?;
932    if !output.success() {
933        return None;
934    }
935    Some(
936        output
937            .stdout
938            .lines()
939            .map(str::trim)
940            .filter(|line| !line.is_empty())
941            .map(str::to_string)
942            .collect(),
943    )
944}
945
946/// Результат проверки «версия уже опубликована».
947#[derive(Debug, Clone)]
948pub struct PublishedCheck {
949    /// Уже опубликована ли версия.
950    pub published: bool,
951    /// Предупреждения (расхождение источников, недоступность API).
952    pub warnings: Vec<String>,
953}
954
955/// Сверяет версию с crates.io API и git-тегами `v<version>`.
956///
957/// Оба источника «уже есть» → публикация запрещена; расхождение — warning и
958/// продолжение; недоступность API — fallback на git-теги с warning.
959pub fn check_already_published(
960    probe: &dyn RegistryProbe,
961    git_tags: Option<&BTreeSet<String>>,
962    name: &str,
963    version: &semver::Version,
964) -> PublishedCheck {
965    let api = match probe.versions(name) {
966        ApiOutcome::Versions(versions) => Some(versions.contains(version)),
967        ApiOutcome::NotFound => Some(false),
968        ApiOutcome::Unavailable => None,
969    };
970    let git = git_tags.map(|tags| tags.contains(&format!("v{version}")));
971
972    let mut warnings = Vec::new();
973    let published = match (api, git) {
974        (Some(true), Some(true)) => true,
975        (Some(false), Some(false)) => false,
976        (Some(api), Some(git)) if api != git => {
977            warnings.push(format!(
978                "crates.io API и git-теги расходятся для {name} v{version}; продолжаю"
979            ));
980            false
981        }
982        (None, Some(true)) => {
983            warnings.push(format!(
984                "crates.io API unreachable, falling back to git tags for {name}"
985            ));
986            true
987        }
988        (None, Some(false)) => {
989            warnings.push(format!(
990                "crates.io API unreachable, falling back to git tags for {name}"
991            ));
992            false
993        }
994        (Some(true), None) => {
995            warnings.push(format!(
996                "git-теги недоступны, доверяю crates.io API для {name}"
997            ));
998            true
999        }
1000        (Some(false), None) => {
1001            warnings.push(format!(
1002                "git-теги недоступны, доверяю crates.io API для {name}"
1003            ));
1004            false
1005        }
1006        (None, None) => {
1007            warnings.push(format!(
1008                "crates.io API недоступен и git-теги недоступны для {name}; проверить не удалось"
1009            ));
1010            false
1011        }
1012        (Some(_), Some(_)) => unreachable!("случай равенства обработан выше"),
1013    };
1014
1015    PublishedCheck {
1016        published,
1017        warnings,
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024    use crate::process::CommandOutput;
1025    use std::collections::VecDeque;
1026    use std::sync::Mutex;
1027
1028    fn package(name: &str, publishable: bool, deps: &[(&str, DepKind, &str)]) -> PackageInfo {
1029        PackageInfo {
1030            name: name.to_string(),
1031            version: semver::Version::new(0, 2, 0),
1032            manifest_path: PathBuf::from(format!("{name}/Cargo.toml")),
1033            publishable,
1034            description: None,
1035            license: None,
1036            repository: None,
1037            readme: None,
1038            deps: deps
1039                .iter()
1040                .map(|(dep_name, kind, req)| InternalDep {
1041                    name: (*dep_name).to_string(),
1042                    req_raw: (*req).to_string(),
1043                    req: semver::VersionReq::parse(req).expect("req"),
1044                    kind: *kind,
1045                })
1046                .collect(),
1047        }
1048    }
1049
1050    fn workspace(packages: Vec<PackageInfo>) -> Workspace {
1051        let mut by_name = BTreeMap::new();
1052        for (index, package) in packages.iter().enumerate() {
1053            by_name.insert(package.name.clone(), index);
1054        }
1055        Workspace {
1056            root: PathBuf::from("."),
1057            packages,
1058            by_name,
1059        }
1060    }
1061
1062    fn ok_output() -> CommandOutput {
1063        CommandOutput {
1064            status: 0,
1065            stdout: String::new(),
1066            stderr: String::new(),
1067        }
1068    }
1069
1070    fn fail_output(stderr: &str) -> CommandOutput {
1071        CommandOutput {
1072            status: 101,
1073            stdout: String::new(),
1074            stderr: stderr.to_string(),
1075        }
1076    }
1077
1078    struct ScriptedRunner {
1079        outputs: Mutex<VecDeque<CommandOutput>>,
1080        calls: Mutex<Vec<Vec<String>>>,
1081    }
1082
1083    impl ScriptedRunner {
1084        fn new(outputs: Vec<CommandOutput>) -> Self {
1085            Self {
1086                outputs: Mutex::new(outputs.into()),
1087                calls: Mutex::new(Vec::new()),
1088            }
1089        }
1090        fn call_count(&self) -> usize {
1091            self.calls.lock().expect("calls").len()
1092        }
1093    }
1094
1095    impl CommandRunner for ScriptedRunner {
1096        fn run(
1097            &self,
1098            _program: &str,
1099            args: &[String],
1100            _cwd: Option<&Path>,
1101        ) -> Result<CommandOutput> {
1102            self.calls.lock().expect("calls").push(args.to_vec());
1103            Ok(self
1104                .outputs
1105                .lock()
1106                .expect("outputs")
1107                .pop_front()
1108                .expect("scripted output"))
1109        }
1110    }
1111
1112    struct RecordingSleeper {
1113        sleeps: Mutex<Vec<Duration>>,
1114    }
1115
1116    impl RecordingSleeper {
1117        fn new() -> Self {
1118            Self {
1119                sleeps: Mutex::new(Vec::new()),
1120            }
1121        }
1122        fn sleeps(&self) -> Vec<Duration> {
1123            self.sleeps.lock().expect("sleeps").clone()
1124        }
1125    }
1126
1127    impl Sleeper for RecordingSleeper {
1128        fn sleep(&self, duration: Duration) {
1129            self.sleeps.lock().expect("sleeps").push(duration);
1130        }
1131    }
1132
1133    struct MockProbe {
1134        outcome: ApiOutcome,
1135    }
1136
1137    impl RegistryProbe for MockProbe {
1138        fn versions(&self, _name: &str) -> ApiOutcome {
1139            self.outcome.clone()
1140        }
1141    }
1142
1143    #[test]
1144    fn topological_order_puts_dependencies_first() {
1145        let ws = workspace(vec![
1146            package(
1147                "light-fw-cli",
1148                true,
1149                &[("light-fw-cli-core", DepKind::Normal, "^0.2.0")],
1150            ),
1151            package("light-fw-core", true, &[]),
1152            package(
1153                "light-fw-cli-core",
1154                true,
1155                &[("light-fw-core", DepKind::Normal, "^0.2.0")],
1156            ),
1157        ]);
1158        let plan = plan(&ws, None).expect("plan");
1159        assert_eq!(
1160            plan.order,
1161            vec!["light-fw-core", "light-fw-cli-core", "light-fw-cli"]
1162        );
1163    }
1164
1165    #[test]
1166    fn dev_dependencies_do_not_create_edges() {
1167        let ws = workspace(vec![
1168            package("a", true, &[]),
1169            package("b", true, &[("a", DepKind::Dev, "^0.2.0")]),
1170        ]);
1171        let plan = plan(&ws, None).expect("plan");
1172        assert_eq!(plan.order, vec!["a", "b"]);
1173    }
1174
1175    #[test]
1176    fn publish_false_is_skipped() {
1177        let ws = workspace(vec![
1178            package("light-fw-core", true, &[]),
1179            package(
1180                "light-demo",
1181                false,
1182                &[("light-fw-core", DepKind::Normal, "^0.2.0")],
1183            ),
1184        ]);
1185        let plan = plan(&ws, None).expect("plan");
1186        assert_eq!(plan.order, vec!["light-fw-core"]);
1187        assert_eq!(plan.skipped, vec!["light-demo"]);
1188    }
1189
1190    #[test]
1191    fn publishable_depending_on_unpublished_is_error() {
1192        let ws = workspace(vec![
1193            package("app", true, &[("dep", DepKind::Normal, "^0.2.0")]),
1194            package("dep", false, &[]),
1195        ]);
1196        let error = plan(&ws, None).expect_err("должна быть ошибка");
1197        assert!(
1198            error.to_string().contains("publish = false"),
1199            "неожиданная ошибка: {error}"
1200        );
1201    }
1202
1203    #[test]
1204    fn dependency_cycle_is_error() {
1205        let ws = workspace(vec![
1206            package("a", true, &[("b", DepKind::Normal, "^0.2.0")]),
1207            package("b", true, &[("a", DepKind::Normal, "^0.2.0")]),
1208        ]);
1209        let error = plan(&ws, None).expect_err("цикл");
1210        assert!(error.to_string().contains("цикл"), "{error}");
1211    }
1212
1213    #[test]
1214    fn single_crate_selects_one() {
1215        let ws = workspace(vec![
1216            package("a", true, &[]),
1217            package("b", true, &[("a", DepKind::Normal, "^0.2.0")]),
1218        ]);
1219        let plan = plan(&ws, Some("b")).expect("plan");
1220        assert_eq!(plan.order, vec!["b"]);
1221    }
1222
1223    #[test]
1224    fn unknown_single_crate_is_error() {
1225        let ws = workspace(vec![package("a", true, &[])]);
1226        let error = plan(&ws, Some("missing")).expect_err("не найден");
1227        assert!(error.to_string().contains("не является членом"), "{error}");
1228    }
1229
1230    #[test]
1231    fn single_unpublished_crate_is_error() {
1232        let ws = workspace(vec![package("demo", false, &[])]);
1233        let error = plan(&ws, Some("demo")).expect_err("publish = false");
1234        assert!(error.to_string().contains("publish = false"), "{error}");
1235    }
1236
1237    #[test]
1238    fn version_consistency_detects_mismatch() {
1239        let ws = workspace(vec![
1240            package("core", true, &[]),
1241            package("comp", true, &[("core", DepKind::Normal, "^0.1.0")]),
1242        ]);
1243        let plan = plan(&ws, None).expect("plan");
1244        let error = check_version_consistency(&ws, &plan).expect_err("mismatch");
1245        assert!(error.to_string().contains("должны совпадать"), "{error}");
1246    }
1247
1248    #[test]
1249    fn version_consistency_accepts_matching_dependency() {
1250        let ws = workspace(vec![
1251            package("core", true, &[]),
1252            package("comp", true, &[("core", DepKind::Normal, "^0.2.0")]),
1253        ]);
1254        let plan = plan(&ws, None).expect("plan");
1255        assert!(check_version_consistency(&ws, &plan).is_ok());
1256    }
1257
1258    #[test]
1259    fn metadata_warnings_list_missing_fields() {
1260        let ws = workspace(vec![package("bare", true, &[]), {
1261            let mut p = package("full", true, &[]);
1262            p.description = Some("d".into());
1263            p.license = Some("MIT".into());
1264            p.repository = Some("https://example.com".into());
1265            p.readme = Some("README.md".into());
1266            p
1267        }]);
1268        let plan = plan(&ws, None).expect("plan");
1269        let warnings = metadata_warnings(&ws, &plan);
1270        assert_eq!(warnings.len(), 1);
1271        assert!(warnings[0].starts_with("bare: missing "), "{warnings:?}");
1272        assert!(warnings[0].contains("readme"), "{warnings:?}");
1273    }
1274
1275    #[test]
1276    fn classify_detects_index_staleness() {
1277        assert_eq!(
1278            classify_publish_failure("error: failed to select a version for the requirement"),
1279            FailureKind::IndexStale
1280        );
1281        assert_eq!(
1282            classify_publish_failure("no matching package named `light-fw-core` found"),
1283            FailureKind::IndexStale
1284        );
1285        assert_eq!(
1286            classify_publish_failure("error: api token not provided"),
1287            FailureKind::Other
1288        );
1289    }
1290
1291    #[test]
1292    fn classify_detects_rate_limit() {
1293        assert_eq!(
1294            classify_publish_failure(
1295                "the remote server responded with an error (status 429 Too Many Requests)"
1296            ),
1297            FailureKind::RateLimited
1298        );
1299        assert_eq!(
1300            classify_publish_failure(
1301                "You have hit the limit for the number of too many new crates"
1302            ),
1303            FailureKind::RateLimited
1304        );
1305    }
1306
1307    #[test]
1308    fn classify_detects_already_published() {
1309        assert_eq!(
1310            classify_publish_failure(
1311                "error: crate light-fw-html@0.6.1 already exists on crates.io index"
1312            ),
1313            FailureKind::AlreadyPublished
1314        );
1315        assert_eq!(
1316            classify_publish_failure("error: crate version `0.6.1` is already uploaded"),
1317            FailureKind::AlreadyPublished
1318        );
1319    }
1320
1321    #[test]
1322    fn rate_limit_reset_extracts_timestamp() {
1323        let stderr = "error: the remote server responded with an error (status 429 Too Many \
1324                      Requests): too many new crates. Please try again after \
1325                      2026-10-20T12:34:56Z.";
1326        assert_eq!(
1327            rate_limit_reset(stderr).as_deref(),
1328            Some("2026-10-20T12:34:56Z")
1329        );
1330    }
1331
1332    #[test]
1333    fn rate_limit_reset_is_optional() {
1334        assert_eq!(rate_limit_reset("status 429 Too Many Requests"), None);
1335        assert_eq!(rate_limit_reset("api token not provided"), None);
1336    }
1337
1338    #[test]
1339    fn publish_does_not_retry_rate_limit() {
1340        let runner = ScriptedRunner::new(vec![fail_output(
1341            "error: status 429 Too Many Requests: too many new crates",
1342        )]);
1343        let sleeper = RecordingSleeper::new();
1344        let attempt = publish_with_retries(
1345            &runner,
1346            &sleeper,
1347            Path::new("."),
1348            "light-fw",
1349            &PublishInvocation::default(),
1350            &RetryConfig::default(),
1351        )
1352        .expect("publish");
1353        assert!(!attempt.success);
1354        assert_eq!(attempt.retries, 0);
1355        assert!(sleeper.sleeps().is_empty());
1356        assert_eq!(runner.call_count(), 1);
1357        assert_eq!(
1358            classify_publish_failure(&attempt.stderr),
1359            FailureKind::RateLimited
1360        );
1361    }
1362
1363    #[test]
1364    fn publish_treats_already_published_as_skip_without_retry() {
1365        let runner = ScriptedRunner::new(vec![fail_output(
1366            "error: crate light-fw-html@0.6.1 already exists on crates.io index",
1367        )]);
1368        let sleeper = RecordingSleeper::new();
1369        let attempt = publish_with_retries(
1370            &runner,
1371            &sleeper,
1372            Path::new("."),
1373            "light-fw-html",
1374            &PublishInvocation::default(),
1375            &RetryConfig::default(),
1376        )
1377        .expect("publish");
1378        assert!(!attempt.success);
1379        assert!(attempt.already_published);
1380        assert_eq!(attempt.retries, 0);
1381        assert!(sleeper.sleeps().is_empty());
1382        assert_eq!(runner.call_count(), 1);
1383    }
1384
1385    #[test]
1386    fn publish_detects_already_published_in_stdout() {
1387        let runner = ScriptedRunner::new(vec![CommandOutput {
1388            status: 101,
1389            stdout: "error: crate version `0.6.1` is already uploaded".to_string(),
1390            stderr: String::new(),
1391        }]);
1392        let sleeper = RecordingSleeper::new();
1393        let attempt = publish_with_retries(
1394            &runner,
1395            &sleeper,
1396            Path::new("."),
1397            "light-fw-html",
1398            &PublishInvocation::default(),
1399            &RetryConfig::default(),
1400        )
1401        .expect("publish");
1402        assert!(!attempt.success);
1403        assert!(attempt.already_published);
1404        assert!(sleeper.sleeps().is_empty());
1405    }
1406
1407    #[test]
1408    fn retry_backoff_is_exponential() {
1409        let config = RetryConfig {
1410            max_retries: 4,
1411            base: Duration::from_secs(5),
1412            factor: 2,
1413        };
1414        assert_eq!(config.delay(0), Duration::from_secs(5));
1415        assert_eq!(config.delay(1), Duration::from_secs(10));
1416        assert_eq!(config.delay(2), Duration::from_secs(20));
1417        assert_eq!(config.delay(3), Duration::from_secs(40));
1418    }
1419
1420    #[test]
1421    fn publish_retries_index_staleness_then_succeeds() {
1422        let runner = ScriptedRunner::new(vec![
1423            fail_output("no matching package named `light-fw-core` found"),
1424            ok_output(),
1425        ]);
1426        let sleeper = RecordingSleeper::new();
1427        let config = RetryConfig {
1428            max_retries: 3,
1429            base: Duration::from_secs(5),
1430            factor: 2,
1431        };
1432        let attempt = publish_with_retries(
1433            &runner,
1434            &sleeper,
1435            Path::new("."),
1436            "light-fw",
1437            &PublishInvocation::default(),
1438            &config,
1439        )
1440        .expect("publish");
1441        assert!(attempt.success);
1442        assert_eq!(attempt.retries, 1);
1443        assert_eq!(sleeper.sleeps(), vec![Duration::from_secs(5)]);
1444        assert_eq!(runner.call_count(), 2);
1445    }
1446
1447    #[test]
1448    fn publish_stops_immediately_on_other_error() {
1449        let runner = ScriptedRunner::new(vec![fail_output("error: api token not provided")]);
1450        let sleeper = RecordingSleeper::new();
1451        let attempt = publish_with_retries(
1452            &runner,
1453            &sleeper,
1454            Path::new("."),
1455            "light-fw",
1456            &PublishInvocation::default(),
1457            &RetryConfig::default(),
1458        )
1459        .expect("publish");
1460        assert!(!attempt.success);
1461        assert_eq!(attempt.retries, 0);
1462        assert!(sleeper.sleeps().is_empty());
1463        assert_eq!(runner.call_count(), 1);
1464    }
1465
1466    #[test]
1467    fn publish_exhausts_retries() {
1468        let runner = ScriptedRunner::new(vec![
1469            fail_output("failed to select a version"),
1470            fail_output("failed to select a version"),
1471        ]);
1472        let sleeper = RecordingSleeper::new();
1473        let config = RetryConfig {
1474            max_retries: 1,
1475            base: Duration::from_secs(5),
1476            factor: 2,
1477        };
1478        let attempt = publish_with_retries(
1479            &runner,
1480            &sleeper,
1481            Path::new("."),
1482            "light-fw",
1483            &PublishInvocation::default(),
1484            &config,
1485        )
1486        .expect("publish");
1487        assert!(!attempt.success);
1488        assert_eq!(attempt.retries, 1);
1489        assert_eq!(sleeper.sleeps(), vec![Duration::from_secs(5)]);
1490        assert_eq!(runner.call_count(), 2);
1491    }
1492
1493    #[test]
1494    fn publish_args_shape() {
1495        let invocation = PublishInvocation {
1496            verify: false,
1497            allow_dirty: true,
1498            token: Some("secret".into()),
1499        };
1500        assert_eq!(
1501            publish_args("light-fw", &invocation),
1502            vec![
1503                "publish",
1504                "-p",
1505                "light-fw",
1506                "--no-verify",
1507                "--allow-dirty",
1508                "--token",
1509                "secret"
1510            ]
1511        );
1512        let verifying = PublishInvocation {
1513            verify: true,
1514            ..PublishInvocation::default()
1515        };
1516        assert_eq!(
1517            publish_args("light-fw", &verifying),
1518            vec!["publish", "-p", "light-fw"]
1519        );
1520    }
1521
1522    #[test]
1523    fn package_list_args_shape() {
1524        assert_eq!(
1525            package_list_args("light-fw-core"),
1526            vec!["package", "--list", "-p", "light-fw-core", "--allow-dirty"]
1527        );
1528    }
1529
1530    #[test]
1531    fn gate_commands_apply_exclude_to_build_test_clippy() {
1532        let commands = gate_commands(Some("light-fw-cli"));
1533        let names: Vec<&str> = commands.iter().map(|(name, _)| *name).collect();
1534        assert_eq!(names, vec!["build", "test", "clippy", "fmt"]);
1535        for (name, args) in &commands {
1536            let has_exclude = args.iter().any(|arg| arg == "--exclude");
1537            if *name == "fmt" {
1538                assert!(!has_exclude, "cargo fmt не поддерживает --exclude");
1539            } else {
1540                assert!(has_exclude, "{name} должен исключать запущенный крейт");
1541            }
1542            if *name == "clippy" {
1543                assert_eq!(args.last().map(String::as_str), Some("warnings"));
1544            }
1545        }
1546    }
1547
1548    #[test]
1549    fn gate_commands_without_exclude_are_plain() {
1550        let commands = gate_commands(None);
1551        assert!(
1552            commands
1553                .iter()
1554                .all(|(_, args)| !args.iter().any(|arg| arg == "--exclude"))
1555        );
1556    }
1557
1558    #[test]
1559    fn already_published_when_both_sources_agree() {
1560        let probe = MockProbe {
1561            outcome: ApiOutcome::Versions(vec![semver::Version::new(0, 2, 0)]),
1562        };
1563        let mut tags = BTreeSet::new();
1564        tags.insert("v0.2.0".to_string());
1565        let check = check_already_published(
1566            &probe,
1567            Some(&tags),
1568            "light-fw",
1569            &semver::Version::new(0, 2, 0),
1570        );
1571        assert!(check.published);
1572        assert!(check.warnings.is_empty());
1573    }
1574
1575    #[test]
1576    fn already_published_disagreement_warns_and_continues() {
1577        let probe = MockProbe {
1578            outcome: ApiOutcome::Versions(vec![semver::Version::new(0, 2, 0)]),
1579        };
1580        let tags = BTreeSet::new();
1581        let check = check_already_published(
1582            &probe,
1583            Some(&tags),
1584            "light-fw",
1585            &semver::Version::new(0, 2, 0),
1586        );
1587        assert!(!check.published);
1588        assert_eq!(check.warnings.len(), 1);
1589        assert!(
1590            check.warnings[0].contains("расходятся"),
1591            "{:?}",
1592            check.warnings
1593        );
1594    }
1595
1596    #[test]
1597    fn api_unreachable_falls_back_to_git_tags() {
1598        let probe = MockProbe {
1599            outcome: ApiOutcome::Unavailable,
1600        };
1601        let mut tags = BTreeSet::new();
1602        tags.insert("v0.2.0".to_string());
1603        let check = check_already_published(
1604            &probe,
1605            Some(&tags),
1606            "light-fw",
1607            &semver::Version::new(0, 2, 0),
1608        );
1609        assert!(check.published);
1610        assert!(
1611            check.warnings[0].contains("falling back to git tags"),
1612            "{:?}",
1613            check.warnings
1614        );
1615    }
1616
1617    #[test]
1618    fn not_found_on_registry_is_not_published() {
1619        let probe = MockProbe {
1620            outcome: ApiOutcome::NotFound,
1621        };
1622        let tags = BTreeSet::new();
1623        let check = check_already_published(
1624            &probe,
1625            Some(&tags),
1626            "light-fw",
1627            &semver::Version::new(0, 2, 0),
1628        );
1629        assert!(!check.published);
1630        assert!(check.warnings.is_empty());
1631    }
1632}