1use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Context, Result, bail};
6use qk_config::{Target, Workspace};
7use qk_graph::{ProjectGraph, select_projects};
8use serde::Serialize;
9use serde_json::Value;
10
11#[derive(Clone, Debug)]
12pub struct Request {
13 pub project: String,
14 pub target: String,
15 pub configuration: Option<String>,
16 pub requested_configuration: Option<String>,
18 pub args: Vec<String>,
19}
20
21impl Request {
22 pub fn parse(value: &str) -> Result<Self> {
23 let parts: Vec<_> = value.split(':').collect();
24 if !(2..=3).contains(&parts.len()) || parts.iter().any(|part| part.is_empty()) {
25 bail!("expected project:target[:configuration], got {value:?}");
26 }
27 Ok(Self {
28 project: parts[0].into(),
29 target: parts[1].into(),
30 configuration: parts.get(2).map(|part| (*part).into()),
31 requested_configuration: parts.get(2).map(|part| (*part).into()),
32 args: Vec::new(),
33 })
34 }
35
36 pub fn parse_in(workspace: &Workspace, value: &str) -> Result<Self> {
38 let Some((project, identifier)) = value.split_once(':') else {
39 return Self::parse(value);
40 };
41 if let Some(project_config) = workspace.projects.get(project) {
42 if project_config.targets.contains_key(identifier) {
43 return Ok(Self {
44 project: project.into(),
45 target: identifier.into(),
46 configuration: None,
47 requested_configuration: None,
48 args: Vec::new(),
49 });
50 }
51 if let Some((target, configuration)) = identifier.rsplit_once(':')
52 && project_config.targets.contains_key(target)
53 && !configuration.is_empty()
54 {
55 return Ok(Self {
56 project: project.into(),
57 target: target.into(),
58 configuration: Some(configuration.into()),
59 requested_configuration: Some(configuration.into()),
60 args: Vec::new(),
61 });
62 }
63 }
64 Self::parse(value)
65 }
66}
67
68#[derive(Clone, Debug, Serialize)]
69pub struct Task {
70 pub id: String,
71 pub project: String,
72 pub target: String,
73 pub configuration: Option<String>,
74 pub args: Vec<String>,
75 pub definition: Target,
76 pub dependencies: BTreeSet<String>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct TaskGraph {
81 pub roots: BTreeSet<String>,
82 pub tasks: BTreeMap<String, Task>,
83 #[serde(skip)]
85 pub cycles: Vec<Vec<String>>,
86}
87
88#[derive(Clone, Copy, Debug, Default)]
90pub struct BuildOptions {
91 pub exclude_task_dependencies: bool,
94 pub ignore_cycles: bool,
97}
98
99impl TaskGraph {
100 pub fn build(workspace: &Workspace, requests: &[Request]) -> Result<Self> {
101 Self::build_with(workspace, requests, BuildOptions::default())
102 }
103
104 pub fn build_with(
105 workspace: &Workspace,
106 requests: &[Request],
107 options: BuildOptions,
108 ) -> Result<Self> {
109 if requests.is_empty() {
110 bail!("no tasks selected");
111 }
112 let projects = ProjectGraph::build(workspace)?;
113 let mut builder = Builder {
114 workspace,
115 projects,
116 tasks: BTreeMap::new(),
117 visiting: Vec::new(),
118 visited: BTreeSet::new(),
119 ignore_cycles: options.ignore_cycles,
120 cycles: Vec::new(),
121 };
122 let mut roots = BTreeSet::new();
123 for request in requests {
124 roots.insert(builder.visit(request.clone(), true)?.with_context(|| {
125 format!(
126 "project {:?} has no target {:?}",
127 request.project, request.target
128 )
129 })?);
130 }
131 let mut graph = Self {
132 roots,
133 tasks: builder.tasks,
134 cycles: builder.cycles,
135 };
136 if !graph.cycles.is_empty() {
137 graph.make_acyclic();
138 }
139 if options.exclude_task_dependencies {
140 let roots = graph.roots.clone();
141 graph.tasks.retain(|id, _| roots.contains(id));
142 for task in graph.tasks.values_mut() {
143 task.dependencies.retain(|id| roots.contains(id));
144 }
145 }
146 Ok(graph)
147 }
148
149 fn make_acyclic(&mut self) {
152 fn visit(
153 graph: &mut BTreeMap<String, Task>,
154 id: &str,
155 visited: &mut BTreeSet<String>,
156 path: &mut Vec<String>,
157 ) {
158 if !visited.insert(id.to_owned()) {
159 return;
160 }
161 let dependencies: Vec<String> = graph[id].dependencies.iter().cloned().collect();
162 for dependency in dependencies {
163 if path.contains(&dependency) {
164 graph.get_mut(id).unwrap().dependencies.remove(&dependency);
165 } else {
166 path.push(dependency.clone());
167 visit(graph, &dependency, visited, path);
168 path.pop();
169 }
170 }
171 }
172 let ids: Vec<String> = self.tasks.keys().cloned().collect();
173 let mut visited = BTreeSet::new();
174 for id in ids {
175 let mut path = vec![id.clone()];
176 visit(&mut self.tasks, &id, &mut visited, &mut path);
177 }
178 }
179}
180
181struct Builder<'a> {
182 workspace: &'a Workspace,
183 projects: ProjectGraph,
184 tasks: BTreeMap<String, Task>,
185 visiting: Vec<String>,
186 visited: BTreeSet<(String, Option<String>)>,
187 ignore_cycles: bool,
188 cycles: Vec<Vec<String>>,
189}
190
191impl Builder<'_> {
192 fn visit(&mut self, request: Request, required: bool) -> Result<Option<String>> {
193 let project = self
194 .workspace
195 .projects
196 .get(&request.project)
197 .with_context(|| format!("unknown project {:?}", request.project))?;
198 let Some(target) = project.targets.get(&request.target) else {
199 if required {
200 bail!(
201 "project {:?} has no target {:?}",
202 request.project,
203 request.target
204 );
205 }
206 return Ok(None);
207 };
208 let requested_configuration = request.requested_configuration.clone();
209 let configuration = match request.configuration {
210 Some(name) if target.configurations.contains_key(&name) => Some(name),
211 Some(name) if required => bail!(
212 "{}:{} has no configuration {name:?}",
213 request.project,
214 request.target
215 ),
216 _ => target.default_configuration.clone(),
217 };
218 let id = format!(
219 "{}:{}{}",
220 request.project,
221 request.target,
222 configuration
223 .as_ref()
224 .map(|c| format!(":{c}"))
225 .unwrap_or_default()
226 );
227 if let Some(position) = self.visiting.iter().position(|task| task == &id) {
228 let mut cycle = self.visiting[position..].to_vec();
229 cycle.push(id.clone());
230 if !self.ignore_cycles {
231 bail!("task dependency cycle: {}", cycle.join(" -> "));
232 }
233 self.cycles.push(cycle);
235 return Ok(Some(id));
236 }
237 if let Some(existing) = self.tasks.get(&id) {
238 if existing.args != request.args {
239 bail!("task {id} requested with conflicting forwarded arguments");
240 }
241 if self
242 .visited
243 .contains(&(id.clone(), requested_configuration.clone()))
244 {
245 return Ok(Some(id));
246 }
247 }
248 let mut definition = target.clone();
249 if let Some(name) = &configuration {
250 let options = target
251 .configurations
252 .get(name)
253 .with_context(|| format!("{id}: default configuration does not exist"))?;
254 for (key, value) in options {
255 if key == "outputs" {
256 definition.outputs =
257 Some(serde_json::from_value(value.clone()).with_context(|| {
258 format!("{id}: configuration outputs must be an array of paths")
259 })?);
260 } else {
261 if key == "env" {
262 value.as_object().with_context(|| {
263 format!("{id}: configuration env must be an object")
264 })?;
265 }
266 definition.options.insert(key.clone(), value.clone());
267 }
268 }
269 }
270 let task = Task {
271 id: id.clone(),
272 project: request.project,
273 target: request.target,
274 configuration,
275 args: request.args,
276 definition,
277 dependencies: BTreeSet::new(),
278 };
279 self.visiting.push(id.clone());
280 let mut dependencies = self
281 .tasks
282 .get(&id)
283 .map(|task| task.dependencies.clone())
284 .unwrap_or_default();
285 self.collect(
286 &task,
287 &task.project,
288 requested_configuration.as_deref(),
289 &mut dependencies,
290 &mut BTreeSet::new(),
291 )?;
292 self.visiting.pop();
293 self.visited.insert((id.clone(), requested_configuration));
294 self.tasks.insert(
295 id.clone(),
296 Task {
297 dependencies,
298 ..task
299 },
300 );
301 Ok(Some(id))
302 }
303
304 fn collect(
309 &mut self,
310 task: &Task,
311 derive_from: &str,
312 requested_configuration: Option<&str>,
313 dependencies: &mut BTreeSet<String>,
314 seen: &mut BTreeSet<String>,
315 ) -> Result<()> {
316 if !seen.insert(derive_from.to_owned()) {
317 return Ok(());
318 }
319 for dependency in task.definition.depends_on.as_deref().unwrap_or_default() {
320 let (requests, derived) = self
321 .dependencies(task, derive_from, requested_configuration, dependency)
322 .with_context(|| format!("dependsOn of {}", task.id))?;
323 for request in requests {
324 let has_target = self.workspace.projects[&request.project]
325 .targets
326 .contains_key(&request.target);
327 if derived && !has_target {
328 let project = request.project;
329 self.collect(task, &project, requested_configuration, dependencies, seen)?;
330 } else if request.project == task.project && request.target == task.target {
331 continue;
333 } else if let Some(id) = self.visit(request, false)? {
334 dependencies.insert(id);
335 }
336 }
337 }
338 Ok(())
339 }
340
341 fn dependencies(
344 &self,
345 task: &Task,
346 derive_from: &str,
347 requested_configuration: Option<&str>,
348 value: &Value,
349 ) -> Result<(Vec<Request>, bool)> {
350 let mut forward_options = false;
351 let (target, projects, forward, derived) = match value {
352 Value::String(value) => {
353 if let Some(target) = value.strip_prefix('^') {
354 (
355 target.to_owned(),
356 self.project_dependencies(derive_from),
357 false,
358 true,
359 )
360 } else if let Some((project, target)) = value.split_once(':') {
361 (target.to_owned(), vec![project.to_owned()], false, false)
362 } else {
363 (value.clone(), vec![task.project.clone()], false, false)
364 }
365 }
366 Value::Object(object) => {
367 let target = object
368 .get("target")
369 .and_then(Value::as_str)
370 .context("dependency target must be a string")?;
371 let forward = match object.get("params").and_then(Value::as_str) {
372 None if !object.contains_key("params") => false,
373 Some("ignore") => false,
374 Some("forward") => true,
375 _ => bail!("dependency params must be forward or ignore"),
376 };
377 forward_options = match object.get("options").and_then(Value::as_str) {
378 None if !object.contains_key("options") => false,
379 Some("ignore") => false,
380 Some("forward") => true,
381 _ => bail!("dependency options must be forward or ignore"),
382 };
383 for key in object.keys() {
384 if !matches!(
385 key.as_str(),
386 "target" | "projects" | "dependencies" | "params" | "options"
387 ) {
388 bail!("unsupported dependency field {key:?}");
389 }
390 }
391 let dependencies = match object.get("dependencies") {
392 Some(value) => value
393 .as_bool()
394 .context("dependency dependencies must be boolean")?,
395 None => false,
396 };
397 if dependencies && object.contains_key("projects") {
398 bail!("dependency cannot specify both projects and dependencies: true");
399 }
400 let projects = if dependencies {
401 self.project_dependencies(derive_from)
402 } else if let Some(selectors) = object.get("projects") {
403 let mut selectors: Vec<String> = match selectors {
404 Value::String(selector) => vec![selector.clone()],
405 value => serde_json::from_value(value.clone())
406 .context("dependency projects must be a string or string array")?,
407 };
408 for selector in &mut selectors {
409 if selector == "self" {
410 *selector = task.project.clone();
411 } else if selector == "!self" {
412 *selector = format!("!{}", task.project);
413 }
414 }
415 select_projects(&self.workspace.projects, &selectors, &[])?
416 .into_iter()
417 .collect()
418 } else {
419 vec![task.project.clone()]
420 };
421 (target.to_owned(), projects, forward, dependencies)
422 }
423 _ => bail!("dependency must be a string or object"),
424 };
425 if target.is_empty() || target.contains(':') || target.starts_with('^') {
426 bail!("invalid dependency target {target:?}");
427 }
428 let targets: Vec<String> = if qk_config::is_glob(&target) {
431 let matcher = globset::Glob::new(&target)
432 .with_context(|| format!("invalid dependency target {target:?}"))?
433 .compile_matcher();
434 self.workspace
435 .projects
436 .values()
437 .flat_map(|project| project.targets.keys())
438 .filter(|name| matcher.is_match(name.as_str()))
439 .cloned()
440 .collect::<BTreeSet<_>>()
441 .into_iter()
442 .collect()
443 } else {
444 vec![target]
445 };
446 let mut args = Vec::new();
450 if forward_options {
451 for (key, value) in &task.definition.options {
452 option_flags(key, value, &mut args);
453 }
454 }
455 if forward {
456 args.extend(task.args.iter().cloned());
457 }
458 let requests = targets
459 .iter()
460 .flat_map(|target| {
461 projects.iter().map(|project| Request {
462 project: project.clone(),
463 target: target.clone(),
464 configuration: requested_configuration.map(str::to_owned),
465 requested_configuration: requested_configuration.map(str::to_owned),
466 args: args.clone(),
467 })
468 })
469 .collect();
470 Ok((requests, derived))
471 }
472
473 fn project_dependencies(&self, project: &str) -> Vec<String> {
474 self.projects.dependencies[project]
476 .iter()
477 .map(|edge| edge.target.clone())
478 .collect::<BTreeSet<_>>()
479 .into_iter()
480 .collect()
481 }
482}
483
484fn option_flags(key: &str, value: &Value, flags: &mut Vec<String>) {
488 match value {
489 Value::Object(fields) => {
490 for (field, value) in fields {
491 option_flags(&format!("{key}.{field}"), value, flags);
492 }
493 }
494 Value::String(text) => flags.push(format!("--{key}={text}")),
495 Value::Number(number) => flags.push(format!("--{key}={number}")),
496 Value::Bool(flag) => flags.push(format!("--{key}={flag}")),
497 Value::Array(_) | Value::Null => {}
498 }
499}