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    /// Прочая ошибка — ретраи не помогут.
604    Other,
605}
606
607/// Паттерны stderr, означающие «индекс ещё не обновился».
608///
609/// Список сознательно вынесен в константу и легко расширяется.
610pub const INDEX_STALE_PATTERNS: &[&str] = &[
611    "failed to select a version",
612    "no matching package named",
613    "could not be found in registry index",
614];
615
616/// Классифицирует stderr `cargo publish` (exit code всегда 101, поэтому — по тексту).
617pub fn classify_publish_failure(stderr: &str) -> FailureKind {
618    let lower = stderr.to_lowercase();
619    if INDEX_STALE_PATTERNS
620        .iter()
621        .any(|pattern| lower.contains(pattern))
622    {
623        FailureKind::IndexStale
624    } else {
625        FailureKind::Other
626    }
627}
628
629/// Настройки ретраев на устаревание индекса.
630#[derive(Debug, Clone, Copy)]
631pub struct RetryConfig {
632    /// Сколько раз повторить попытку (не считая первой).
633    pub max_retries: u32,
634    /// Базовая задержка.
635    pub base: Duration,
636    /// Множитель экспоненциального backoff.
637    pub factor: u32,
638}
639
640impl Default for RetryConfig {
641    fn default() -> Self {
642        Self {
643            max_retries: 6,
644            base: Duration::from_secs(5),
645            factor: 2,
646        }
647    }
648}
649
650impl RetryConfig {
651    /// Читает настройки из окружения (`LIGHT_PUBLISH_RETRY_*`).
652    ///
653    /// - `LIGHT_PUBLISH_RETRY_MAX` — число повторов (по умолчанию 6);
654    /// - `LIGHT_PUBLISH_RETRY_BASE_MS` — базовая задержка в мс (по умолчанию 5000);
655    /// - `LIGHT_PUBLISH_RETRY_FACTOR` — множитель (по умолчанию 2).
656    pub fn from_env() -> Self {
657        let mut config = Self::default();
658        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_MAX")
659            && let Ok(parsed) = value.parse()
660        {
661            config.max_retries = parsed;
662        }
663        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_BASE_MS")
664            && let Ok(parsed) = value.parse::<u64>()
665        {
666            config.base = Duration::from_millis(parsed);
667        }
668        if let Ok(value) = std::env::var("LIGHT_PUBLISH_RETRY_FACTOR")
669            && let Ok(parsed) = value.parse::<u32>()
670            && parsed >= 1
671        {
672            config.factor = parsed;
673        }
674        config
675    }
676
677    /// Задержка перед повтором номер `retry_index` (с нуля).
678    pub fn delay(&self, retry_index: u32) -> Duration {
679        let mut delay = self.base;
680        for _ in 0..retry_index {
681            delay = delay.saturating_mul(self.factor);
682        }
683        delay
684    }
685}
686
687/// Ожидание между ретраями (инжектируется, чтобы тесты не спали).
688pub trait Sleeper: Send + Sync {
689    /// Спит `duration`.
690    fn sleep(&self, duration: Duration);
691}
692
693/// Реальный [`Sleeper`] на `std::thread::sleep`.
694#[derive(Debug, Default, Clone, Copy)]
695pub struct SystemSleeper;
696
697impl Sleeper for SystemSleeper {
698    fn sleep(&self, duration: Duration) {
699        std::thread::sleep(duration);
700    }
701}
702
703/// Итог публикации одного крейта.
704#[derive(Debug, Clone)]
705pub struct PublishAttempt {
706    /// Успешно ли опубликован.
707    pub success: bool,
708    /// Число сделанных повторов.
709    pub retries: u32,
710    /// stderr последней неудачной попытки.
711    pub stderr: String,
712}
713
714/// Публикует крейт с ретраями на устаревание индекса crates.io.
715///
716/// Ретраи делаются только для [`FailureKind::IndexStale`]; прочие ошибки
717/// возвращаются сразу.
718pub fn publish_with_retries(
719    runner: &dyn CommandRunner,
720    sleeper: &dyn Sleeper,
721    root: &Path,
722    name: &str,
723    invocation: &PublishInvocation,
724    retry: &RetryConfig,
725) -> Result<PublishAttempt> {
726    let args = publish_args(name, invocation);
727    let mut retries = 0;
728    loop {
729        let output = runner.run("cargo", &args, Some(root))?;
730        if output.success() {
731            return Ok(PublishAttempt {
732                success: true,
733                retries,
734                stderr: String::new(),
735            });
736        }
737        let stderr = output.stderr;
738        if classify_publish_failure(&stderr) == FailureKind::Other || retries >= retry.max_retries {
739            return Ok(PublishAttempt {
740                success: false,
741                retries,
742                stderr,
743            });
744        }
745        sleeper.sleep(retry.delay(retries));
746        retries += 1;
747    }
748}
749
750/// Итог проверки crates.io API.
751#[derive(Debug, Clone, PartialEq, Eq)]
752pub enum ApiOutcome {
753    /// Реестр вернул список версий.
754    Versions(Vec<semver::Version>),
755    /// Крейт в реестре не найден.
756    NotFound,
757    /// Реестр недоступен (нет сети, ошибка API).
758    Unavailable,
759}
760
761/// Источник данных об опубликованных версиях crates.io.
762pub trait RegistryProbe: Send + Sync {
763    /// Возвращает список версий крейта на crates.io.
764    fn versions(&self, name: &str) -> ApiOutcome;
765}
766
767/// Реальный опрос crates.io API.
768#[derive(Debug, Default, Clone, Copy)]
769pub struct CratesIoProbe;
770
771impl RegistryProbe for CratesIoProbe {
772    fn versions(&self, name: &str) -> ApiOutcome {
773        #[cfg(feature = "network")]
774        {
775            crates_io_versions(name)
776        }
777        #[cfg(not(feature = "network"))]
778        {
779            let _ = name;
780            ApiOutcome::Unavailable
781        }
782    }
783}
784
785#[cfg(feature = "network")]
786fn crates_io_versions(name: &str) -> ApiOutcome {
787    #[derive(Deserialize)]
788    struct Response {
789        versions: Vec<VersionEntry>,
790    }
791    #[derive(Deserialize)]
792    struct VersionEntry {
793        num: String,
794    }
795
796    let client = match reqwest::blocking::Client::builder()
797        .timeout(Duration::from_secs(10))
798        .build()
799    {
800        Ok(client) => client,
801        Err(_) => return ApiOutcome::Unavailable,
802    };
803    let url = format!("https://crates.io/api/v1/crates/{name}");
804    let response = match client
805        .get(&url)
806        .header("User-Agent", "light-fw-cli (publish pre-flight)")
807        .send()
808    {
809        Ok(response) => response,
810        Err(_) => return ApiOutcome::Unavailable,
811    };
812    if response.status() == reqwest::StatusCode::NOT_FOUND {
813        return ApiOutcome::NotFound;
814    }
815    let response = match response.error_for_status() {
816        Ok(response) => response,
817        Err(_) => return ApiOutcome::Unavailable,
818    };
819    match response.json::<Response>() {
820        Ok(parsed) => ApiOutcome::Versions(
821            parsed
822                .versions
823                .iter()
824                .filter_map(|entry| semver::Version::parse(&entry.num).ok())
825                .collect(),
826        ),
827        Err(_) => ApiOutcome::Unavailable,
828    }
829}
830
831/// Локальные git-теги (используются как fallback к crates.io API).
832pub fn git_tags(runner: &dyn CommandRunner, root: &Path) -> Option<BTreeSet<String>> {
833    let output = runner
834        .run(
835            "git",
836            &["tag".to_string(), "--list".to_string()],
837            Some(root),
838        )
839        .ok()?;
840    if !output.success() {
841        return None;
842    }
843    Some(
844        output
845            .stdout
846            .lines()
847            .map(str::trim)
848            .filter(|line| !line.is_empty())
849            .map(str::to_string)
850            .collect(),
851    )
852}
853
854/// Результат проверки «версия уже опубликована».
855#[derive(Debug, Clone)]
856pub struct PublishedCheck {
857    /// Уже опубликована ли версия.
858    pub published: bool,
859    /// Предупреждения (расхождение источников, недоступность API).
860    pub warnings: Vec<String>,
861}
862
863/// Сверяет версию с crates.io API и git-тегами `v<version>`.
864///
865/// Оба источника «уже есть» → публикация запрещена; расхождение — warning и
866/// продолжение; недоступность API — fallback на git-теги с warning.
867pub fn check_already_published(
868    probe: &dyn RegistryProbe,
869    git_tags: Option<&BTreeSet<String>>,
870    name: &str,
871    version: &semver::Version,
872) -> PublishedCheck {
873    let api = match probe.versions(name) {
874        ApiOutcome::Versions(versions) => Some(versions.contains(version)),
875        ApiOutcome::NotFound => Some(false),
876        ApiOutcome::Unavailable => None,
877    };
878    let git = git_tags.map(|tags| tags.contains(&format!("v{version}")));
879
880    let mut warnings = Vec::new();
881    let published = match (api, git) {
882        (Some(true), Some(true)) => true,
883        (Some(false), Some(false)) => false,
884        (Some(api), Some(git)) if api != git => {
885            warnings.push(format!(
886                "crates.io API и git-теги расходятся для {name} v{version}; продолжаю"
887            ));
888            false
889        }
890        (None, Some(true)) => {
891            warnings.push(format!(
892                "crates.io API unreachable, falling back to git tags for {name}"
893            ));
894            true
895        }
896        (None, Some(false)) => {
897            warnings.push(format!(
898                "crates.io API unreachable, falling back to git tags for {name}"
899            ));
900            false
901        }
902        (Some(true), None) => {
903            warnings.push(format!(
904                "git-теги недоступны, доверяю crates.io API для {name}"
905            ));
906            true
907        }
908        (Some(false), None) => {
909            warnings.push(format!(
910                "git-теги недоступны, доверяю crates.io API для {name}"
911            ));
912            false
913        }
914        (None, None) => {
915            warnings.push(format!(
916                "crates.io API недоступен и git-теги недоступны для {name}; проверить не удалось"
917            ));
918            false
919        }
920        (Some(_), Some(_)) => unreachable!("случай равенства обработан выше"),
921    };
922
923    PublishedCheck {
924        published,
925        warnings,
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use crate::process::CommandOutput;
933    use std::collections::VecDeque;
934    use std::sync::Mutex;
935
936    fn package(name: &str, publishable: bool, deps: &[(&str, DepKind, &str)]) -> PackageInfo {
937        PackageInfo {
938            name: name.to_string(),
939            version: semver::Version::new(0, 2, 0),
940            manifest_path: PathBuf::from(format!("{name}/Cargo.toml")),
941            publishable,
942            description: None,
943            license: None,
944            repository: None,
945            readme: None,
946            deps: deps
947                .iter()
948                .map(|(dep_name, kind, req)| InternalDep {
949                    name: (*dep_name).to_string(),
950                    req_raw: (*req).to_string(),
951                    req: semver::VersionReq::parse(req).expect("req"),
952                    kind: *kind,
953                })
954                .collect(),
955        }
956    }
957
958    fn workspace(packages: Vec<PackageInfo>) -> Workspace {
959        let mut by_name = BTreeMap::new();
960        for (index, package) in packages.iter().enumerate() {
961            by_name.insert(package.name.clone(), index);
962        }
963        Workspace {
964            root: PathBuf::from("."),
965            packages,
966            by_name,
967        }
968    }
969
970    fn ok_output() -> CommandOutput {
971        CommandOutput {
972            status: 0,
973            stdout: String::new(),
974            stderr: String::new(),
975        }
976    }
977
978    fn fail_output(stderr: &str) -> CommandOutput {
979        CommandOutput {
980            status: 101,
981            stdout: String::new(),
982            stderr: stderr.to_string(),
983        }
984    }
985
986    struct ScriptedRunner {
987        outputs: Mutex<VecDeque<CommandOutput>>,
988        calls: Mutex<Vec<Vec<String>>>,
989    }
990
991    impl ScriptedRunner {
992        fn new(outputs: Vec<CommandOutput>) -> Self {
993            Self {
994                outputs: Mutex::new(outputs.into()),
995                calls: Mutex::new(Vec::new()),
996            }
997        }
998        fn call_count(&self) -> usize {
999            self.calls.lock().expect("calls").len()
1000        }
1001    }
1002
1003    impl CommandRunner for ScriptedRunner {
1004        fn run(
1005            &self,
1006            _program: &str,
1007            args: &[String],
1008            _cwd: Option<&Path>,
1009        ) -> Result<CommandOutput> {
1010            self.calls.lock().expect("calls").push(args.to_vec());
1011            Ok(self
1012                .outputs
1013                .lock()
1014                .expect("outputs")
1015                .pop_front()
1016                .expect("scripted output"))
1017        }
1018    }
1019
1020    struct RecordingSleeper {
1021        sleeps: Mutex<Vec<Duration>>,
1022    }
1023
1024    impl RecordingSleeper {
1025        fn new() -> Self {
1026            Self {
1027                sleeps: Mutex::new(Vec::new()),
1028            }
1029        }
1030        fn sleeps(&self) -> Vec<Duration> {
1031            self.sleeps.lock().expect("sleeps").clone()
1032        }
1033    }
1034
1035    impl Sleeper for RecordingSleeper {
1036        fn sleep(&self, duration: Duration) {
1037            self.sleeps.lock().expect("sleeps").push(duration);
1038        }
1039    }
1040
1041    struct MockProbe {
1042        outcome: ApiOutcome,
1043    }
1044
1045    impl RegistryProbe for MockProbe {
1046        fn versions(&self, _name: &str) -> ApiOutcome {
1047            self.outcome.clone()
1048        }
1049    }
1050
1051    #[test]
1052    fn topological_order_puts_dependencies_first() {
1053        let ws = workspace(vec![
1054            package(
1055                "light-fw-cli",
1056                true,
1057                &[("light-fw-cli-core", DepKind::Normal, "^0.2.0")],
1058            ),
1059            package("light-fw-core", true, &[]),
1060            package(
1061                "light-fw-cli-core",
1062                true,
1063                &[("light-fw-core", DepKind::Normal, "^0.2.0")],
1064            ),
1065        ]);
1066        let plan = plan(&ws, None).expect("plan");
1067        assert_eq!(
1068            plan.order,
1069            vec!["light-fw-core", "light-fw-cli-core", "light-fw-cli"]
1070        );
1071    }
1072
1073    #[test]
1074    fn dev_dependencies_do_not_create_edges() {
1075        let ws = workspace(vec![
1076            package("a", true, &[]),
1077            package("b", true, &[("a", DepKind::Dev, "^0.2.0")]),
1078        ]);
1079        let plan = plan(&ws, None).expect("plan");
1080        assert_eq!(plan.order, vec!["a", "b"]);
1081    }
1082
1083    #[test]
1084    fn publish_false_is_skipped() {
1085        let ws = workspace(vec![
1086            package("light-fw-core", true, &[]),
1087            package(
1088                "light-demo",
1089                false,
1090                &[("light-fw-core", DepKind::Normal, "^0.2.0")],
1091            ),
1092        ]);
1093        let plan = plan(&ws, None).expect("plan");
1094        assert_eq!(plan.order, vec!["light-fw-core"]);
1095        assert_eq!(plan.skipped, vec!["light-demo"]);
1096    }
1097
1098    #[test]
1099    fn publishable_depending_on_unpublished_is_error() {
1100        let ws = workspace(vec![
1101            package("app", true, &[("dep", DepKind::Normal, "^0.2.0")]),
1102            package("dep", false, &[]),
1103        ]);
1104        let error = plan(&ws, None).expect_err("должна быть ошибка");
1105        assert!(
1106            error.to_string().contains("publish = false"),
1107            "неожиданная ошибка: {error}"
1108        );
1109    }
1110
1111    #[test]
1112    fn dependency_cycle_is_error() {
1113        let ws = workspace(vec![
1114            package("a", true, &[("b", DepKind::Normal, "^0.2.0")]),
1115            package("b", true, &[("a", DepKind::Normal, "^0.2.0")]),
1116        ]);
1117        let error = plan(&ws, None).expect_err("цикл");
1118        assert!(error.to_string().contains("цикл"), "{error}");
1119    }
1120
1121    #[test]
1122    fn single_crate_selects_one() {
1123        let ws = workspace(vec![
1124            package("a", true, &[]),
1125            package("b", true, &[("a", DepKind::Normal, "^0.2.0")]),
1126        ]);
1127        let plan = plan(&ws, Some("b")).expect("plan");
1128        assert_eq!(plan.order, vec!["b"]);
1129    }
1130
1131    #[test]
1132    fn unknown_single_crate_is_error() {
1133        let ws = workspace(vec![package("a", true, &[])]);
1134        let error = plan(&ws, Some("missing")).expect_err("не найден");
1135        assert!(error.to_string().contains("не является членом"), "{error}");
1136    }
1137
1138    #[test]
1139    fn single_unpublished_crate_is_error() {
1140        let ws = workspace(vec![package("demo", false, &[])]);
1141        let error = plan(&ws, Some("demo")).expect_err("publish = false");
1142        assert!(error.to_string().contains("publish = false"), "{error}");
1143    }
1144
1145    #[test]
1146    fn version_consistency_detects_mismatch() {
1147        let ws = workspace(vec![
1148            package("core", true, &[]),
1149            package("comp", true, &[("core", DepKind::Normal, "^0.1.0")]),
1150        ]);
1151        let plan = plan(&ws, None).expect("plan");
1152        let error = check_version_consistency(&ws, &plan).expect_err("mismatch");
1153        assert!(error.to_string().contains("должны совпадать"), "{error}");
1154    }
1155
1156    #[test]
1157    fn version_consistency_accepts_matching_dependency() {
1158        let ws = workspace(vec![
1159            package("core", true, &[]),
1160            package("comp", true, &[("core", DepKind::Normal, "^0.2.0")]),
1161        ]);
1162        let plan = plan(&ws, None).expect("plan");
1163        assert!(check_version_consistency(&ws, &plan).is_ok());
1164    }
1165
1166    #[test]
1167    fn metadata_warnings_list_missing_fields() {
1168        let ws = workspace(vec![package("bare", true, &[]), {
1169            let mut p = package("full", true, &[]);
1170            p.description = Some("d".into());
1171            p.license = Some("MIT".into());
1172            p.repository = Some("https://example.com".into());
1173            p.readme = Some("README.md".into());
1174            p
1175        }]);
1176        let plan = plan(&ws, None).expect("plan");
1177        let warnings = metadata_warnings(&ws, &plan);
1178        assert_eq!(warnings.len(), 1);
1179        assert!(warnings[0].starts_with("bare: missing "), "{warnings:?}");
1180        assert!(warnings[0].contains("readme"), "{warnings:?}");
1181    }
1182
1183    #[test]
1184    fn classify_detects_index_staleness() {
1185        assert_eq!(
1186            classify_publish_failure("error: failed to select a version for the requirement"),
1187            FailureKind::IndexStale
1188        );
1189        assert_eq!(
1190            classify_publish_failure("no matching package named `light-fw-core` found"),
1191            FailureKind::IndexStale
1192        );
1193        assert_eq!(
1194            classify_publish_failure("error: api token not provided"),
1195            FailureKind::Other
1196        );
1197    }
1198
1199    #[test]
1200    fn retry_backoff_is_exponential() {
1201        let config = RetryConfig {
1202            max_retries: 4,
1203            base: Duration::from_secs(5),
1204            factor: 2,
1205        };
1206        assert_eq!(config.delay(0), Duration::from_secs(5));
1207        assert_eq!(config.delay(1), Duration::from_secs(10));
1208        assert_eq!(config.delay(2), Duration::from_secs(20));
1209        assert_eq!(config.delay(3), Duration::from_secs(40));
1210    }
1211
1212    #[test]
1213    fn publish_retries_index_staleness_then_succeeds() {
1214        let runner = ScriptedRunner::new(vec![
1215            fail_output("no matching package named `light-fw-core` found"),
1216            ok_output(),
1217        ]);
1218        let sleeper = RecordingSleeper::new();
1219        let config = RetryConfig {
1220            max_retries: 3,
1221            base: Duration::from_secs(5),
1222            factor: 2,
1223        };
1224        let attempt = publish_with_retries(
1225            &runner,
1226            &sleeper,
1227            Path::new("."),
1228            "light",
1229            &PublishInvocation::default(),
1230            &config,
1231        )
1232        .expect("publish");
1233        assert!(attempt.success);
1234        assert_eq!(attempt.retries, 1);
1235        assert_eq!(sleeper.sleeps(), vec![Duration::from_secs(5)]);
1236        assert_eq!(runner.call_count(), 2);
1237    }
1238
1239    #[test]
1240    fn publish_stops_immediately_on_other_error() {
1241        let runner = ScriptedRunner::new(vec![fail_output("error: api token not provided")]);
1242        let sleeper = RecordingSleeper::new();
1243        let attempt = publish_with_retries(
1244            &runner,
1245            &sleeper,
1246            Path::new("."),
1247            "light",
1248            &PublishInvocation::default(),
1249            &RetryConfig::default(),
1250        )
1251        .expect("publish");
1252        assert!(!attempt.success);
1253        assert_eq!(attempt.retries, 0);
1254        assert!(sleeper.sleeps().is_empty());
1255        assert_eq!(runner.call_count(), 1);
1256    }
1257
1258    #[test]
1259    fn publish_exhausts_retries() {
1260        let runner = ScriptedRunner::new(vec![
1261            fail_output("failed to select a version"),
1262            fail_output("failed to select a version"),
1263        ]);
1264        let sleeper = RecordingSleeper::new();
1265        let config = RetryConfig {
1266            max_retries: 1,
1267            base: Duration::from_secs(5),
1268            factor: 2,
1269        };
1270        let attempt = publish_with_retries(
1271            &runner,
1272            &sleeper,
1273            Path::new("."),
1274            "light",
1275            &PublishInvocation::default(),
1276            &config,
1277        )
1278        .expect("publish");
1279        assert!(!attempt.success);
1280        assert_eq!(attempt.retries, 1);
1281        assert_eq!(sleeper.sleeps(), vec![Duration::from_secs(5)]);
1282        assert_eq!(runner.call_count(), 2);
1283    }
1284
1285    #[test]
1286    fn publish_args_shape() {
1287        let invocation = PublishInvocation {
1288            verify: false,
1289            allow_dirty: true,
1290            token: Some("secret".into()),
1291        };
1292        assert_eq!(
1293            publish_args("light", &invocation),
1294            vec![
1295                "publish",
1296                "-p",
1297                "light",
1298                "--no-verify",
1299                "--allow-dirty",
1300                "--token",
1301                "secret"
1302            ]
1303        );
1304        let verifying = PublishInvocation {
1305            verify: true,
1306            ..PublishInvocation::default()
1307        };
1308        assert_eq!(
1309            publish_args("light", &verifying),
1310            vec!["publish", "-p", "light"]
1311        );
1312    }
1313
1314    #[test]
1315    fn package_list_args_shape() {
1316        assert_eq!(
1317            package_list_args("light-fw-core"),
1318            vec!["package", "--list", "-p", "light-fw-core", "--allow-dirty"]
1319        );
1320    }
1321
1322    #[test]
1323    fn gate_commands_apply_exclude_to_build_test_clippy() {
1324        let commands = gate_commands(Some("light-fw-cli"));
1325        let names: Vec<&str> = commands.iter().map(|(name, _)| *name).collect();
1326        assert_eq!(names, vec!["build", "test", "clippy", "fmt"]);
1327        for (name, args) in &commands {
1328            let has_exclude = args.iter().any(|arg| arg == "--exclude");
1329            if *name == "fmt" {
1330                assert!(!has_exclude, "cargo fmt не поддерживает --exclude");
1331            } else {
1332                assert!(has_exclude, "{name} должен исключать запущенный крейт");
1333            }
1334            if *name == "clippy" {
1335                assert_eq!(args.last().map(String::as_str), Some("warnings"));
1336            }
1337        }
1338    }
1339
1340    #[test]
1341    fn gate_commands_without_exclude_are_plain() {
1342        let commands = gate_commands(None);
1343        assert!(
1344            commands
1345                .iter()
1346                .all(|(_, args)| !args.iter().any(|arg| arg == "--exclude"))
1347        );
1348    }
1349
1350    #[test]
1351    fn already_published_when_both_sources_agree() {
1352        let probe = MockProbe {
1353            outcome: ApiOutcome::Versions(vec![semver::Version::new(0, 2, 0)]),
1354        };
1355        let mut tags = BTreeSet::new();
1356        tags.insert("v0.2.0".to_string());
1357        let check =
1358            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1359        assert!(check.published);
1360        assert!(check.warnings.is_empty());
1361    }
1362
1363    #[test]
1364    fn already_published_disagreement_warns_and_continues() {
1365        let probe = MockProbe {
1366            outcome: ApiOutcome::Versions(vec![semver::Version::new(0, 2, 0)]),
1367        };
1368        let tags = BTreeSet::new();
1369        let check =
1370            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1371        assert!(!check.published);
1372        assert_eq!(check.warnings.len(), 1);
1373        assert!(
1374            check.warnings[0].contains("расходятся"),
1375            "{:?}",
1376            check.warnings
1377        );
1378    }
1379
1380    #[test]
1381    fn api_unreachable_falls_back_to_git_tags() {
1382        let probe = MockProbe {
1383            outcome: ApiOutcome::Unavailable,
1384        };
1385        let mut tags = BTreeSet::new();
1386        tags.insert("v0.2.0".to_string());
1387        let check =
1388            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1389        assert!(check.published);
1390        assert!(
1391            check.warnings[0].contains("falling back to git tags"),
1392            "{:?}",
1393            check.warnings
1394        );
1395    }
1396
1397    #[test]
1398    fn not_found_on_registry_is_not_published() {
1399        let probe = MockProbe {
1400            outcome: ApiOutcome::NotFound,
1401        };
1402        let tags = BTreeSet::new();
1403        let check =
1404            check_already_published(&probe, Some(&tags), "light", &semver::Version::new(0, 2, 0));
1405        assert!(!check.published);
1406        assert!(check.warnings.is_empty());
1407    }
1408}