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