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 Other,
605}
606
607pub 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
616pub 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#[derive(Debug, Clone, Copy)]
631pub struct RetryConfig {
632 pub max_retries: u32,
634 pub base: Duration,
636 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 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 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
687pub trait Sleeper: Send + Sync {
689 fn sleep(&self, duration: Duration);
691}
692
693#[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#[derive(Debug, Clone)]
705pub struct PublishAttempt {
706 pub success: bool,
708 pub retries: u32,
710 pub stderr: String,
712}
713
714pub 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#[derive(Debug, Clone, PartialEq, Eq)]
752pub enum ApiOutcome {
753 Versions(Vec<semver::Version>),
755 NotFound,
757 Unavailable,
759}
760
761pub trait RegistryProbe: Send + Sync {
763 fn versions(&self, name: &str) -> ApiOutcome;
765}
766
767#[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
831pub 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#[derive(Debug, Clone)]
856pub struct PublishedCheck {
857 pub published: bool,
859 pub warnings: Vec<String>,
861}
862
863pub 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}