-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathmod.rs
More file actions
1384 lines (1185 loc) · 58.9 KB
/
mod.rs
File metadata and controls
1384 lines (1185 loc) · 58.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
mod name;
mod task_command;
mod task_graph_builder;
mod workspace;
use std::{
collections::{BTreeMap, BTreeSet},
ffi::OsStr,
future::Future,
sync::Arc,
};
use bincode::{Decode, Encode};
use compact_str::ToCompactString;
use diff::Diff;
use serde::{Deserialize, Serialize};
pub use task_command::*;
pub use task_graph_builder::*;
use vite_path::{self, RelativePath, RelativePathBuf};
use vite_str::Str;
pub use workspace::*;
use crate::{
Error,
cmd::TaskParsedCommand,
collections::{HashMap, HashSet},
config::name::TaskName,
execute::TaskEnvs,
types::ResolveCommandResult,
};
#[derive(Encode, Decode, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Diff)]
#[diff(attr(#[derive(Debug)]))]
#[serde(rename_all = "camelCase")]
pub struct TaskConfig {
pub(crate) command: TaskCommand,
#[serde(default)]
pub(crate) cwd: RelativePathBuf,
pub(crate) cacheable: bool,
#[serde(default)]
pub(crate) inputs: HashSet<Str>,
#[serde(default)]
pub(crate) envs: HashSet<Str>,
#[serde(default)]
pub(crate) pass_through_envs: HashSet<Str>,
#[serde(default)]
pub(crate) fingerprint_ignores: Option<Vec<Str>>,
}
impl TaskConfig {
pub fn set_fingerprint_ignores(&mut self, fingerprint_ignores: Option<Vec<Str>>) {
self.fingerprint_ignores = fingerprint_ignores;
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TaskConfigWithDeps {
#[serde(flatten)]
pub(crate) config: TaskConfig,
#[serde(default)]
pub(crate) depends_on: Vec<Str>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ViteTaskJson {
pub(crate) tasks: HashMap<Str, TaskConfigWithDeps>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct DisplayOptions {
/// Whether to hide the command ("~> echo hello") before the execution.
pub hide_command: bool,
/// Whether to hide this task in the summary after all executions.
pub hide_summary: bool,
/// If true, the task will not be replayed from the cache.
/// This is useful for tasks that should not be replayed, like auto run install command.
/// TODO: this is a temporary solution, we should find a better way to handle this.
pub ignore_replay: bool,
}
/// A resolved task, ready to hit the cache or be executed
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedTask {
pub name: TaskName,
pub args: Arc<[Str]>,
pub resolved_config: ResolvedTaskConfig,
pub resolved_command: ResolvedTaskCommand,
pub display_options: DisplayOptions,
}
impl ResolvedTask {
pub fn id(&self) -> TaskId {
TaskId {
subcommand_index: self.name.subcommand_index,
task_group_id: TaskGroupId {
task_group_name: self.name.task_group_name.clone(),
config_path: self.resolved_config.config_dir.clone(),
is_builtin: self.is_builtin(),
},
}
}
pub const fn is_builtin(&self) -> bool {
self.name.package_name.is_none()
}
pub fn matches(&self, task_request: &str, current_package_path: Option<&RelativePath>) -> bool {
if self.name.subcommand_index.is_some() {
// never match non-last subcommand
return false;
}
let Some(package_name) = &self.name.package_name else {
// never match built-in task
return false;
};
// match tasks in current package if the task_request doesn't contain '#'
if !task_request.contains('#') {
return current_package_path == Some(&self.resolved_config.config_dir)
&& self.name.task_group_name == task_request;
}
task_request.get(..package_name.len()) == Some(package_name)
&& task_request.get(package_name.len()..=package_name.len()) == Some("#")
&& task_request.get(package_name.len() + 1..) == Some(&self.name.task_group_name)
}
/// For displaying in the UI.
/// Not necessarily a unique identifier as the package name can be duplicated.
pub fn display_name(&self) -> Str {
self.name.to_compact_string().into()
}
#[tracing::instrument(skip(workspace, resolve_command, args))]
/// Resolve a built-in task, like `vite lint`, `vite build`
pub async fn resolve_from_builtin<
Resolved: Future<Output = Result<ResolveCommandResult, Error>>,
ResolveFn: Fn() -> Resolved,
>(
workspace: &Workspace,
resolve_command: ResolveFn,
task_name: &str,
args: impl Iterator<Item = impl AsRef<str>> + Clone,
) -> Result<Self, Error> {
let ResolveCommandResult { bin_path, envs } = resolve_command().await?;
Self::resolve_from_builtin_with_command_result(
workspace,
task_name,
args,
ResolveCommandResult { bin_path, envs },
false,
None,
)
}
pub fn resolve_from_builtin_with_command_result(
workspace: &Workspace,
task_name: &str,
args: impl Iterator<Item = impl AsRef<str>> + Clone,
command_result: ResolveCommandResult,
ignore_replay: bool,
fingerprint_ignores: Option<Vec<Str>>,
) -> Result<Self, Error> {
let ResolveCommandResult { bin_path, envs } = command_result;
let builtin_task = TaskCommand::Parsed(TaskParsedCommand {
args: args.clone().map(|arg| arg.as_ref().into()).collect(),
envs: envs.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
program: bin_path.into(),
});
let mut task_config: TaskConfig = builtin_task.clone().into();
task_config.set_fingerprint_ignores(fingerprint_ignores.clone());
let pass_through_envs = task_config.pass_through_envs.iter().cloned().collect();
let cwd = &workspace.cwd;
let resolved_task_config =
ResolvedTaskConfig { config_dir: cwd.clone(), config: task_config };
let resolved_envs =
TaskEnvs::resolve(std::env::vars_os(), &workspace.root_dir, &resolved_task_config)?;
let resolved_command = ResolvedTaskCommand {
fingerprint: CommandFingerprint {
cwd: cwd.clone(),
command: builtin_task,
envs_without_pass_through: resolved_envs
.envs_without_pass_through
.into_iter()
.collect(),
pass_through_envs,
fingerprint_ignores,
},
all_envs: resolved_envs.all_envs,
};
Ok(Self {
name: TaskName {
package_name: None,
task_group_name: task_name.into(),
subcommand_index: None,
},
args: args.map(|arg| arg.as_ref().into()).collect(),
resolved_config: resolved_task_config,
resolved_command,
display_options: DisplayOptions {
// built-in tasks don't show the actual command.
// For example, `vite lint`'s actual command is the path to the bundled oxlint,
// We don't want to show that to the user.
//
// When built-in command like `vite lint` is run as the script of a user-defined task, the script itself
// will be displayed as the command in the inner runner.
hide_command: true,
hide_summary: false,
ignore_replay,
},
})
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ResolvedTaskCommand {
pub fingerprint: CommandFingerprint,
pub all_envs: HashMap<Str, Arc<OsStr>>,
}
impl std::fmt::Debug for ResolvedTaskCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if std::env::var("VITE_DEBUG_VERBOSE").map(|v| v != "0" && v != "false").unwrap_or(false) {
write!(
f,
"ResolvedTaskCommand {{ fingerprint: {:?}, all_envs: {:?} }}",
self.fingerprint, self.all_envs
)
} else {
write!(f, "ResolvedTaskCommand {{ fingerprint: {:?} }}", self.fingerprint)
}
}
}
/// Fingerprint for command execution that affects caching.
///
/// # Environment Variable Impact on Cache
///
/// The `envs_without_pass_through` field is crucial for cache correctness:
/// - Only includes envs explicitly declared in the task's `envs` array
/// - Does NOT include pass-through envs (PATH, CI, etc.)
/// - These envs become part of the cache key
///
/// When a task runs:
/// 1. All envs (including pass-through) are available to the process
/// 2. Only declared envs affect the cache key
/// 3. If a declared env changes value, cache will miss
/// 4. If a pass-through env changes, cache will still hit
///
/// For built-in tasks (lint, build, etc):
/// - The resolver provides envs which become part of the fingerprint
/// - If resolver provides different envs between runs, cache breaks
/// - Each built-in task type must have unique task name to avoid cache collision
///
/// # Fingerprint Ignores Impact on Cache
///
/// The `fingerprint_ignores` field controls which files are tracked in `PostRunFingerprint`:
/// - Changes to this config must invalidate the cache
/// - Vec maintains insertion order (pattern order matters for last-match-wins semantics)
/// - Even though ignore patterns only affect `PostRunFingerprint`, the config itself is part of the cache key
#[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Diff, Clone)]
#[diff(attr(#[derive(Debug)]))]
pub struct CommandFingerprint {
pub cwd: RelativePathBuf,
pub command: TaskCommand,
/// Environment variables that affect caching (excludes pass-through envs)
pub envs_without_pass_through: BTreeMap<Str, Str>, // using BTreeMap to have a stable order in cache db
/// even though value changes to `pass_through_envs` shouldn't invalidate the cache,
/// The names should still be fingerprinted so that the cache can be invalidated if the `pass_through_envs` config changes
pub pass_through_envs: BTreeSet<Str>, // using BTreeSet to have a stable order in cache db
/// Glob patterns for fingerprint filtering. Order matters (last match wins).
/// Changes to this config invalidate the cache to ensure correct fingerprint tracking.
pub fingerprint_ignores: Option<Vec<Str>>,
}
#[cfg(test)]
mod tests {
use petgraph::stable_graph::StableDiGraph;
use super::*;
use crate::{
Error,
test_utils::{get_fixture_path, with_unique_cache_path},
};
#[test]
fn test_recursive_topological_build() {
with_unique_cache_path("recursive_topological_build", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test recursive topological build
let task_graph = workspace
.build_task_subgraph(&["build".into()], Arc::default(), true)
.expect("Failed to resolve tasks");
// Verify that all build tasks are included
let task_names: Vec<_> =
task_graph.node_weights().map(super::ResolvedTask::display_name).collect();
assert!(task_names.contains(&"@test/core#build".into()));
assert!(task_names.contains(&"@test/utils#build".into()));
assert!(task_names.contains(&"@test/app#build".into()));
assert!(task_names.contains(&"@test/web#build".into()));
// Verify dependencies exist in the correct direction
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// With topological mode, edges go from dependencies to dependents
assert!(
has_edge("@test/utils#build(subcommand 0)", "@test/core#build"),
"Core should have edge to Utils (Utils depends on Core)"
);
assert!(
has_edge("@test/app#build", "@test/utils#build"),
"Utils should have edge to App (App depends on Utils)"
);
assert!(
has_edge("@test/web#build", "@test/app#build"),
"App should have edge to Web (Web depends on App)"
);
assert!(
has_edge("@test/web#build", "@test/core#build"),
"Core should have edge to Web (Web depends on Core)"
);
// TODO: fix indirect dependencies
// assert!(
// !has_edge("@test/web#build", "@test/utils#build"),
// "Web should have edge to utils (It should be indirect via App)"
// );
});
}
#[test]
fn test_topological_run_false_no_implicit_deps() {
with_unique_cache_path("topological_run_false", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
// Load with topological_run = false
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), false)
.expect("Failed to load workspace");
let task_graph = workspace
.build_task_subgraph(&["@test/web#build".into()], Arc::default(), false)
.expect("Failed to resolve tasks");
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// When topological_run is false, @test/web#build should NOT depend on @test/core#build
// even though @test/web depends on @test/core as a package dependency
assert!(
!has_edge("@test/core#build", "@test/web#build"),
"With topological_run=false, Core#build should NOT have edge to Web#build"
);
});
}
#[test]
fn test_explicit_deps_with_topological_false() {
with_unique_cache_path("explicit_deps_topological_false", |cache_path| {
let fixture_path = get_fixture_path("fixtures/explicit-deps-workspace");
// Load with topological_run = false
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), false)
.expect("Failed to load workspace");
// Test @test/utils#lint which has explicit dependencies
let task_graph = workspace
.build_task_subgraph(&["@test/utils#lint".into()], Arc::default(), false)
.expect("Failed to resolve tasks");
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// Verify explicit dependencies are honored
assert!(
has_edge("@test/utils#lint", "@test/core#build"),
"Explicit dependency from utils#lint to core#build should exist"
);
assert!(
has_edge("@test/utils#lint", "@test/utils#build"),
"Explicit dependency from utils#build to utils#lint should exist"
);
// Verify NO implicit dependencies from package dependencies
// Even though @test/utils depends on @test/core, utils#build should NOT depend on core#build
assert!(
!has_edge("@test/core#build", "@test/utils#build"),
"With topological_run=false, no implicit dependency should exist"
);
});
}
#[test]
fn test_explicit_deps_with_topological_true() {
with_unique_cache_path("explicit_deps_topological_true", |cache_path| {
let fixture_path = get_fixture_path("fixtures/explicit-deps-workspace");
// Load with topological_run = true
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test @test/utils#lint which has explicit dependencies
let task_graph = workspace
.build_task_subgraph(&["@test/utils#lint".into()], Arc::default(), false)
.expect("Failed to resolve tasks");
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// Verify explicit dependencies are still honored
assert!(
has_edge("@test/utils#lint", "@test/core#build"),
"Explicit dependency from core#build to utils#lint should exist"
);
assert!(
has_edge("@test/utils#lint", "@test/utils#build"),
"Explicit dependency from utils#build to utils#lint should exist"
);
// Verify implicit dependencies ARE added
assert!(
has_edge("@test/utils#build", "@test/core#build"),
"With topological_run=true, implicit dependency should exist"
);
});
}
#[test]
fn test_recursive_with_topological_false() {
with_unique_cache_path("recursive_topological_false", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
// Load with topological_run = false
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), false)
.expect("Failed to load workspace");
// Test recursive build with topological_run=false
let task_graph = workspace
.build_task_subgraph(&["build".into()], Arc::default(), true)
.expect("Failed to resolve tasks");
// Verify that all build tasks are included (recursive flag works)
let task_names: Vec<_> =
task_graph.node_weights().map(super::ResolvedTask::display_name).collect();
assert!(task_names.contains(&"@test/core#build".into()));
assert!(task_names.contains(&"@test/utils#build".into()));
assert!(task_names.contains(&"@test/app#build".into()));
assert!(task_names.contains(&"@test/web#build".into()));
// But verify NO implicit dependencies exist
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// With topological_run=false, these implicit dependencies should NOT exist
assert!(
!has_edge("@test/core#build", "@test/utils#build"),
"No implicit edge from core to utils"
);
assert!(
!has_edge("@test/utils#build", "@test/app#build"),
"No implicit edge from utils to app"
);
assert!(
!has_edge("@test/app#build", "@test/web#build"),
"No implicit edge from app to web"
);
});
}
#[test]
fn test_topological_true_vs_false_comparison() {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
// Use separate cache paths to avoid database locking
with_unique_cache_path("topological_comparison_true", |cache_path_true| {
// Load with topological_run = true
let workspace_true =
Workspace::load_with_cache_path(fixture_path.clone(), Some(cache_path_true), true)
.expect("Failed to load workspace with topological=true");
let graph_true = workspace_true
.build_task_subgraph(&["@test/app#build".into()], Arc::default(), false)
.expect("Failed to resolve tasks");
with_unique_cache_path("topological_comparison_false", |cache_path_false| {
// Load with topological_run = false
let workspace_false =
Workspace::load_with_cache_path(fixture_path, Some(cache_path_false), false)
.expect("Failed to load workspace with topological=false");
let graph_false = workspace_false
.build_task_subgraph(&["@test/app#build".into()], Arc::default(), false)
.expect("Failed to resolve tasks");
// Count edges in each graph
let edge_count_true = graph_true.edge_count();
let edge_count_false = graph_false.edge_count();
// With topological=true, there should be more edges due to implicit dependencies
assert!(
edge_count_true > edge_count_false,
"Graph with topological=true ({edge_count_true}) should have more edges than topological=false ({edge_count_false})"
);
// Verify specific edge differences
let has_edge =
|graph: &StableDiGraph<ResolvedTask, ()>, from: &str, to: &str| -> bool {
graph.edge_indices().any(|edge_idx| {
let (source, target) = graph.edge_endpoints(edge_idx).unwrap();
graph[source].display_name() == from
&& graph[target].display_name() == to
})
};
// This edge should exist with topological=true but not with topological=false
assert!(
has_edge(&graph_true, "@test/app#build", "@test/utils#build"),
"Implicit edge should exist with topological=true"
);
assert!(
!has_edge(&graph_false, "@test/app#build", "@test/utils#build"),
"Implicit edge should NOT exist with topological=false"
);
});
});
}
#[test]
fn test_recursive_without_topological() {
with_unique_cache_path("recursive_without_topological", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test recursive build without topological flag
// Note: Even without topological flag, cross-package dependencies are now always included
let task_graph = workspace
.build_task_subgraph(&["build".into()], Arc::default(), true)
.expect("Failed to resolve tasks");
// Verify that all build tasks are included
let task_names: Vec<_> =
task_graph.node_weights().map(super::ResolvedTask::display_name).collect();
assert!(task_names.contains(&"@test/core#build".into()));
assert!(task_names.contains(&"@test/utils#build".into()));
assert!(task_names.contains(&"@test/app#build".into()));
assert!(task_names.contains(&"@test/web#build".into()));
// Cross-package dependencies should exist even without topological flag
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// Verify some cross-package dependencies exist
assert!(
has_edge("@test/utils#build(subcommand 0)", "@test/core#build"),
"utils should have edge to core"
);
});
}
#[test]
fn test_recursive_run_with_scope_error() {
with_unique_cache_path("recursive_run_with_scope_error", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test that specifying a scoped task with recursive flag returns an error
let result =
workspace.build_task_subgraph(&["@test/core#build".into()], Arc::default(), true);
assert!(result.is_err());
match result {
Err(Error::RecursiveRunWithScope(task)) => {
assert_eq!(task, "@test/core#build");
}
_ => panic!("Expected RecursiveRunWithScope error"),
}
});
}
#[test]
fn test_non_recursive_single_package() {
with_unique_cache_path("non_recursive_single_package", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test non-recursive build of a single package
let task_graph = workspace
.build_task_subgraph(&["@test/utils#build".into()], Arc::default(), false)
.expect("Failed to resolve tasks");
// @test/utils has compound commands (3 subtasks) plus dependencies on @test/core#build
let all_tasks: Vec<_> =
task_graph.node_weights().map(super::ResolvedTask::display_name).collect();
// Should include utils subtasks
assert!(all_tasks.contains(&"@test/utils#build(subcommand 0)".into()));
assert!(all_tasks.contains(&"@test/utils#build(subcommand 1)".into()));
assert!(all_tasks.contains(&"@test/utils#build".into()));
// Should also include dependency on core
assert!(all_tasks.contains(&"@test/core#build".into()));
});
}
#[test]
fn test_recursive_topological_with_compound_commands() {
with_unique_cache_path("recursive_topological_with_compound_commands", |cache_path| {
let fixture_path = get_fixture_path("fixtures/recursive-topological-workspace");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test recursive topological build with compound commands
let task_graph = workspace
.build_task_subgraph(&["build".into()], Arc::default(), true)
.expect("Failed to resolve tasks");
// Check all tasks including subcommands
let all_tasks: Vec<_> =
task_graph.node_weights().map(super::ResolvedTask::display_name).collect();
// Utils should have 3 subtasks (indices 0, 1, and None)
assert!(all_tasks.contains(&"@test/utils#build(subcommand 0)".into()));
assert!(all_tasks.contains(&"@test/utils#build(subcommand 1)".into()));
assert!(all_tasks.contains(&"@test/utils#build".into()));
// Verify dependencies
let has_edge = |from_name: &str, to_name: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from_name
&& task_graph[target].display_name() == to_name
})
};
// Within-package dependencies for @test/utils compound command
assert!(
has_edge("@test/utils#build(subcommand 1)", "@test/utils#build(subcommand 0)"),
"Second subtask should have edge to first"
);
assert!(
has_edge("@test/utils#build", "@test/utils#build(subcommand 1)"),
"Last subtask should have edge to second"
);
// Cross-package dependencies
// Core's LAST subtask should have edge to utils' FIRST subtask
assert!(
has_edge("@test/utils#build(subcommand 0)", "@test/core#build"),
"Utils' first subtask should have edge to core's last subtask"
);
// Utils' LAST subtask should have edge to app
assert!(
has_edge("@test/app#build", "@test/utils#build"),
"app should have edge to Utils' last subtask"
);
});
}
#[test]
fn test_transitive_dependency_resolution() {
with_unique_cache_path("transitive_dependency_resolution", |cache_path| {
let fixture_path = get_fixture_path("fixtures/transitive-dependency-workspace");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test recursive topological build with transitive dependencies
let task_graph = workspace
.build_task_subgraph(&["build".into()], Arc::default(), true)
.expect("Failed to resolve tasks");
// Verify that all build tasks are included
let task_names: Vec<_> =
task_graph.node_weights().map(super::ResolvedTask::display_name).collect();
assert!(
task_names.contains(&"@test/a#build".into()),
"Package A build task should be included"
);
assert!(
task_names.contains(&"@test/c#build".into()),
"Package C build task should be included"
);
assert_eq!(task_names.len(), 2, "Only A and C should have build tasks");
// Verify dependencies exist in the correct direction
let has_edge = |from: &str, to: &str| -> bool {
task_graph.edge_indices().any(|edge_idx| {
let (source, target) = task_graph.edge_endpoints(edge_idx).unwrap();
task_graph[source].display_name() == from
&& task_graph[target].display_name() == to
})
};
// With transitive dependency resolution, A should have edge to C (A depends on C transitively)
assert!(
has_edge("@test/a#build", "@test/c#build"),
"A should have edge to C (A depends on C transitively through B)"
);
});
}
#[test]
fn test_comprehensive_task_graph() {
with_unique_cache_path("comprehensive_task_graph", |cache_path| {
let fixture_path = get_fixture_path("fixtures/comprehensive-task-graph");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test build task graph
let build_graph = workspace
.build_task_subgraph(&["build".into()], Arc::default(), true)
.expect("Failed to resolve build tasks");
let build_tasks: Vec<_> =
build_graph.node_weights().map(super::ResolvedTask::display_name).collect();
// Verify all packages with build scripts are included
assert!(build_tasks.contains(&"@test/shared#build".into()));
assert!(build_tasks.contains(&"@test/ui#build".into()));
assert!(build_tasks.contains(&"@test/api#build".into()));
assert!(build_tasks.contains(&"@test/app#build".into()));
assert!(build_tasks.contains(&"@test/config#build".into()));
// Tools doesn't have a build script
assert!(!build_tasks.iter().any(|task| task.starts_with("@test/tools#")));
let has_edge =
|graph: &StableDiGraph<ResolvedTask, ()>, from: &str, to: &str| -> bool {
graph.edge_indices().any(|edge_idx| {
let (source, target) = graph.edge_endpoints(edge_idx).unwrap();
graph[source].display_name() == from && graph[target].display_name() == to
})
};
// Verify dependency edges for build tasks (between last subtasks)
assert!(has_edge(&build_graph, "@test/ui#build(subcommand 0)", "@test/shared#build"));
assert!(has_edge(&build_graph, "@test/api#build(subcommand 0)", "@test/shared#build"));
assert!(has_edge(&build_graph, "@test/api#build(subcommand 0)", "@test/config#build"));
assert!(has_edge(&build_graph, "@test/app#build(subcommand 0)", "@test/ui#build"));
assert!(has_edge(&build_graph, "@test/app#build(subcommand 0)", "@test/api#build"));
assert!(has_edge(&build_graph, "@test/app#build(subcommand 0)", "@test/shared#build"));
// Test that UI has compound commands (3 subtasks)
let ui_tasks: Vec<_> = build_graph
.node_weights()
.filter(|task| task.display_name().starts_with("@test/ui#build"))
.map(|task| task.name.subcommand_index)
.collect();
assert_eq!(ui_tasks.len(), 3);
assert!(ui_tasks.contains(&Some(0)));
assert!(ui_tasks.contains(&Some(1)));
assert!(ui_tasks.contains(&None));
// Verify UI compound task internal dependencies
assert!(has_edge(
&build_graph,
"@test/ui#build(subcommand 1)",
"@test/ui#build(subcommand 0)",
));
assert!(has_edge(&build_graph, "@test/ui#build", "@test/ui#build(subcommand 1)"));
// Test that shared has compound commands (3 subtasks for build)
let shared_build_tasks: Vec<_> = build_graph
.node_weights()
.filter(|task| task.display_name().starts_with("@test/shared#build"))
.collect();
assert_eq!(shared_build_tasks.len(), 3);
// Test that API has compound commands (4 subtasks for build)
let api_build_tasks: Vec<_> = build_graph
.node_weights()
.filter(|task| task.display_name().starts_with("@test/api#build"))
.collect();
assert_eq!(api_build_tasks.len(), 4);
// Test that app has compound commands (5 subtasks for build)
let app_build_tasks: Vec<_> = build_graph
.node_weights()
.filter(|task| task.display_name().starts_with("@test/app#build"))
.collect();
assert_eq!(app_build_tasks.len(), 5);
// Verify cross-package dependencies connect to first subtask
assert!(has_edge(&build_graph, "@test/api#build(subcommand 0)", "@test/shared#build"));
assert!(has_edge(&build_graph, "@test/api#build(subcommand 0)", "@test/config#build"));
assert!(has_edge(&build_graph, "@test/app#build(subcommand 0)", "@test/api#build"));
// Test test task graph
let test_graph = workspace
.build_task_subgraph(&["test".into()], Arc::default(), true)
.expect("Failed to resolve test tasks");
let test_tasks: Vec<_> =
test_graph.node_weights().map(super::ResolvedTask::display_name).collect();
assert!(test_tasks.contains(&"@test/shared#test".into()));
assert!(test_tasks.contains(&"@test/ui#test".into()));
assert!(test_tasks.contains(&"@test/api#test".into()));
assert!(test_tasks.contains(&"@test/app#test".into()));
// Config and tools don't have test scripts
assert!(!test_tasks.iter().any(|task| task == "@test/config#test"));
assert!(!test_tasks.iter().any(|task| task == "@test/tools#test"));
// Verify shared#test has compound commands (3 subtasks)
let shared_test_tasks: Vec<_> = test_graph
.node_weights()
.filter(|task| task.display_name().starts_with("@test/shared#test"))
.collect();
assert_eq!(shared_test_tasks.len(), 3);
// Test specific package task
let api_build_graph = workspace
.build_task_subgraph(&["@test/api#build".into()], Arc::default(), false)
.expect("Failed to resolve api build task");
let api_deps: Vec<_> =
api_build_graph.node_weights().map(super::ResolvedTask::display_name).collect();
// Should include api and its dependencies
assert!(api_deps.contains(&"@test/api#build".into()));
assert!(api_deps.contains(&"@test/shared#build".into()));
assert!(api_deps.contains(&"@test/config#build".into()));
// Should not include app or ui
assert!(!api_deps.contains(&"@test/app#build".into()));
assert!(!api_deps.contains(&"@test/ui#build".into()));
});
}
#[test]
fn test_scripts_with_hash_in_names() {
with_unique_cache_path("scripts_with_hash_in_names", |cache_path| {
let fixture_path = get_fixture_path("fixtures/comprehensive-task-graph");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test that we can't use recursive with task names containing # (would be interpreted as scope)
let result =
workspace.build_task_subgraph(&["test#integration".into()], Arc::default(), true);
assert!(result.is_err(), "Recursive run with # in task name should fail");
});
}
#[test]
fn test_task_graph_visualization() {
with_unique_cache_path("task_graph_visualization", |cache_path| {
let fixture_path = get_fixture_path("fixtures/comprehensive-task-graph");
let workspace = Workspace::load_with_cache_path(fixture_path, Some(cache_path), true)
.expect("Failed to load workspace");
// Test app build task graph - this should show the full dependency tree
let app_build_graph = workspace
.build_task_subgraph(&["@test/app#build".into()], Arc::default(), false)
.expect("Failed to resolve app build task");
// Expected task graph structure:
//
// @test/config#build ─────────────────┐
// ▼
// @test/shared#build[0] ──► [1] ──► [None] ──┐
// │ │
// ▼ ▼
// @test/ui#build[0] ──► [1] ──► [None] ──► @test/app#build[0] ──► [1] ──► [2] ──► [3] ──► [None]
// ▲
// @test/api#build[0] ──► [1] ──► [2] ──► [None] ──┘
// ▲
// └─────────────────────────────────────┘
let has_full_edge =
|graph: &StableDiGraph<ResolvedTask, ()>, from_name: &str, to_name: &str| -> bool {
graph.edge_indices().any(|edge_idx| {
let (source, target) = graph.edge_endpoints(edge_idx).unwrap();
graph[source].display_name() == from_name
&& graph[target].display_name() == to_name
})
};
// Verify all tasks are present
let all_tasks: Vec<_> =
app_build_graph.node_weights().map(super::ResolvedTask::display_name).collect();
// App should have 5 subtasks (indices: 0, 1, 2, 3, None)
assert_eq!(
all_tasks.iter().filter(|name| name.starts_with("@test/app#build")).count(),
5
);
// API should have 4 subtasks (indices: 0, 1, 2, None)
assert_eq!(
all_tasks.iter().filter(|name| name.starts_with("@test/api#build")).count(),
4
);
// Shared should have 3 subtasks (indices: 0, 1, None)
assert_eq!(
all_tasks.iter().filter(|name| name.starts_with("@test/shared#build")).count(),
3
);
// UI should have 3 subtasks (indices: 0, 1, None)
assert_eq!(
all_tasks.iter().filter(|name| name.starts_with("@test/ui#build")).count(),
3
);
// Config should have 1 task (no &&)
assert_eq!(
all_tasks.iter().filter(|name| name.starts_with("@test/config#build")).count(),
1
);
// Verify internal task dependencies (within compound commands)
// App internal deps (5 commands => indices 0, 1, 2, 3, None)
assert!(has_full_edge(
&app_build_graph,
"@test/app#build(subcommand 1)",
"@test/app#build(subcommand 0)",
));
assert!(has_full_edge(
&app_build_graph,
"@test/app#build(subcommand 2)",
"@test/app#build(subcommand 1)",
));
assert!(has_full_edge(
&app_build_graph,
"@test/app#build(subcommand 3)",
"@test/app#build(subcommand 2)",
));
assert!(has_full_edge(
&app_build_graph,
"@test/app#build",
"@test/app#build(subcommand 3)",
));
// API internal deps (4 commands => indices 0, 1, 2, None)
assert!(has_full_edge(
&app_build_graph,
"@test/api#build(subcommand 1)",
"@test/api#build(subcommand 0)",
));
assert!(has_full_edge(
&app_build_graph,
"@test/api#build(subcommand 2)",
"@test/api#build(subcommand 1)",