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