1use std::fmt::Write as _;
16
17use crate::registry::PropId;
18use crate::resolve::Resolved;
19use crate::source::SourceKind;
20use crate::value::{one_line, shown};
21
22pub fn explain(resolved: &Resolved, key: &str) -> Option<String> {
37 let registry = resolved.registry();
38 let found = registry.lookup(key)?;
39 let meta = registry.get(found.id);
40 let mut out = String::new();
41
42 if let Some(old) = found.renamed_from {
45 let _ = writeln!(out, "{old} is now {}", meta.key);
46 }
47
48 match resolved.get(found.id) {
49 Some(value) => {
50 let _ = writeln!(out, "{} = {}", meta.key, shown(value));
51 }
52 None => {
53 let _ = writeln!(out, "{} is unset", meta.key);
54 }
55 }
56
57 if let Some(origin) = resolved.origin(found.id) {
58 let verb = match origin.kind {
60 SourceKind::DEFAULTS => "default",
61 SourceKind::COERCED => "derived",
62 _ => "set by",
63 };
64 let _ = writeln!(out, " {verb:<8}{}", one_line(origin.describe()));
65 }
66 let _ = writeln!(out, " {:<8}{}", "type", meta.ty.name());
69 if !meta.choices.is_empty() {
72 let _ = writeln!(out, " {:<8}{}", "one of", one_line(&meta.allowed()));
73 }
74 if let Some(help) = meta.help {
75 let _ = writeln!(out, " {:<8}{}", "", one_line(help));
78 }
79
80 let contributors = resolved.contributors(found.id);
85 if contributors.len() > 1 {
86 let _ = writeln!(out, "\n also considered, lowest precedence first:");
87 for origin in &contributors[..contributors.len() - 1] {
88 let _ = writeln!(out, " {}", one_line(origin.describe()));
93 }
94 }
95
96 if !meta.envs.is_empty() || !meta.cli.is_empty() || !meta.bindings.is_empty() {
102 let _ = writeln!(out);
103 }
104 if !meta.cli.is_empty() {
105 let _ = writeln!(out, " command line {}", one_line(&meta.cli.join(", ")));
108 }
109 if !meta.envs.is_empty() {
110 let _ = writeln!(out, " environment {}", one_line(&meta.envs.join(", ")));
111 }
112 if !meta.bindings.is_empty() {
113 let bindings: Vec<String> = meta
114 .bindings
115 .iter()
116 .map(|(kind, key)| format!("{kind} {key}"))
117 .collect();
118 let _ = writeln!(out, " also {}", one_line(&bindings.join(", ")));
119 }
120 let deprecated = registry.deprecation(found.written);
125 if let Some(why) = deprecated {
126 let _ = writeln!(out, "\n deprecated: {}", one_line(why));
127 }
128
129 Some(out)
130}
131
132pub fn warnings(resolved: &Resolved) -> Vec<String> {
138 resolved
139 .warnings
140 .iter()
141 .map(|warning| {
142 let message = one_line(&warning.message);
143 match &warning.origin {
144 Some(origin) => format!("{message} ({})", one_line(origin.describe())),
148 None => message,
149 }
150 })
151 .collect()
152}
153
154pub fn list(resolved: &Resolved) -> Vec<String> {
160 let registry = resolved.registry();
161 let mut ids: Vec<PropId> = registry
162 .ids()
163 .filter(|id| !registry.get(*id).hide && registry.get(*id).renamed_to.is_none())
164 .collect();
165 ids.sort_by_key(|id| registry.get(*id).key);
166 ids.into_iter()
167 .map(|id| {
168 let meta = registry.get(id);
169 match resolved.get(id) {
170 Some(value) => format!("{} = {}", meta.key, shown(value)),
171 None => format!("{} is unset", meta.key),
172 }
173 })
174 .collect()
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput};
181 use crate::registry::{Merge, PropMeta, Registry, Scope};
182 use crate::resolve::{resolve, Layers};
183 use crate::source::{FileScope, Origin};
184 use crate::ty::Ty;
185 use crate::value::{Const, Value};
186
187 static PROPS: &[PropMeta] = &[
188 PropMeta {
189 default: Some(Const::Int(4)),
190 envs: &["HK_JOBS", "HK_JOB"],
191 cli: &["--jobs", "-j"],
192 bindings: &[("git", "hk.jobs")],
193 help: Some("How many jobs to run at once"),
194 ..PropMeta::new("jobs", Ty::Uint)
195 },
196 PropMeta {
197 merge: Merge::Union,
198 ..PropMeta::new("exclude", Ty::List(&Ty::String))
199 },
200 PropMeta {
201 deprecated: Some("Use jobs instead."),
202 renamed_to: Some("jobs"),
203 ..PropMeta::new("concurrency", Ty::Uint)
204 },
205 PropMeta {
206 hide: true,
207 ..PropMeta::new("internal", Ty::Bool)
208 },
209 PropMeta {
210 scope: Scope::Global,
211 ..PropMeta::new("trusted", Ty::Bool)
212 },
213 PropMeta {
215 bindings: &[("pkl", "hk.stash")],
216 ..PropMeta::new("stash", Ty::String)
217 },
218 ];
219 const REGISTRY: Registry = Registry::new(PROPS);
220
221 struct Fixed(Vec<Entry>);
222
223 impl Layer for Fixed {
224 fn source(&self) -> SourceKind {
225 SourceKind::FILE
226 }
227 fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
228 Ok(LayerOutput {
229 entries: self.0.clone(),
230 warnings: Vec::new(),
231 })
232 }
233 }
234
235 fn id(key: &str) -> PropId {
236 REGISTRY.lookup(key).expect("declared").id
237 }
238
239 #[test]
240 fn an_explanation_names_the_winner_and_what_it_beat() {
241 let env = Fixed(vec![Entry::new(
245 id("jobs"),
246 Value::Int(8),
247 Origin::new(SourceKind::ENV, "HK_JOBS"),
248 )]);
249 let file = Fixed(vec![Entry::new(
250 id("jobs"),
251 Value::Int(2),
252 Origin::file("hk.toml#jobs", FileScope::Project),
253 )]);
254 let resolved =
255 resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
256
257 let text = explain(&resolved, "jobs").expect("declared");
258 assert!(text.starts_with("jobs = 8\n"), "{text}");
259 assert!(text.contains("set by HK_JOBS"), "{text}");
260 assert!(text.contains("type uint"), "{text}");
261 assert!(text.contains("How many jobs to run at once"), "{text}");
262 assert!(text.contains("also considered"), "{text}");
264 assert!(text.contains("the default"), "{text}");
265 assert!(text.contains("hk.toml#jobs"), "{text}");
266 assert_eq!(
267 text.matches("HK_JOBS").count(),
268 2,
269 "once as the winner, once as a place it can be set:\n{text}"
270 );
271 for line in text.lines() {
274 assert_eq!(line, line.trim_end(), "trailing space:\n{text}");
275 }
276 let cli = text.find("command line --jobs, -j").expect(text.as_str());
283 let env = text
284 .find("environment HK_JOBS, HK_JOB")
285 .expect(text.as_str());
286 let also = text.find("also git hk.jobs").expect(text.as_str());
287 assert!(cli < env && env < also, "{text}");
288 }
289
290 #[test]
291 fn a_default_is_not_described_as_something_somebody_set() {
292 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
293 let text = explain(&resolved, "jobs").expect("declared");
294 assert!(text.contains("default the default"), "{text}");
295 assert!(!text.contains("set by"), "nobody set it: {text}");
296 assert!(!text.contains("also considered"), "{text}");
298 }
299
300 #[test]
301 fn a_rewritten_value_says_it_was_derived() {
302 let mut resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
305 resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
306 let text = explain(&resolved, "jobs").expect("declared");
307 assert!(text.contains("jobs = 1"), "{text}");
308 assert!(text.contains("derived raw implies one job"), "{text}");
309 }
310
311 #[test]
312 fn asking_after_an_old_name_answers_about_both() {
313 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
316 let text = explain(&resolved, "concurrency").expect("declared");
317 assert!(text.starts_with("concurrency is now jobs\n"), "{text}");
318 assert!(text.contains("jobs = 4"), "{text}");
319 assert!(text.contains("deprecated: Use jobs instead."), "{text}");
320 }
321
322 #[test]
323 fn a_name_renamed_twice_still_finds_the_notice_along_the_way() {
324 static CHAIN: &[PropMeta] = &[
329 PropMeta {
330 default: Some(Const::Int(4)),
331 ..PropMeta::new("jobs", Ty::Uint)
332 },
333 PropMeta {
334 deprecated: Some("Use jobs instead."),
335 renamed_to: Some("jobs"),
336 ..PropMeta::new("concurrency", Ty::Uint)
337 },
338 PropMeta {
339 renamed_to: Some("concurrency"),
340 ..PropMeta::new("threads", Ty::Uint)
341 },
342 ];
343 const CHAINED: Registry = Registry::new(CHAIN);
344
345 let resolved = resolve(CHAINED, Layers::new()).expect("should resolve");
346 let text = explain(&resolved, "threads").expect("declared");
347 assert!(text.starts_with("threads is now jobs\n"), "{text}");
348 assert!(text.contains("deprecated: Use jobs instead."), "{text}");
349 }
350
351 #[test]
352 fn an_alias_on_a_renamed_setting_keeps_its_deprecation_notice() {
353 static ALIASED: &[PropMeta] = &[
354 PropMeta {
355 default: Some(Const::Int(4)),
356 ..PropMeta::new("jobs", Ty::Uint)
357 },
358 PropMeta {
359 aliases: &["parallelism"],
360 deprecated: Some("Use jobs instead."),
361 renamed_to: Some("jobs"),
362 ..PropMeta::new("concurrency", Ty::Uint)
363 },
364 ];
365 const REGISTRY: Registry = Registry::new(ALIASED);
366
367 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
368 let text = explain(&resolved, "parallelism").expect("declared alias");
369 assert!(text.starts_with("jobs = 4\n"), "{text}");
370 assert!(!text.contains("is now"), "an alias is not a rename: {text}");
371 assert!(text.contains("deprecated: Use jobs instead."), "{text}");
372 }
373
374 #[test]
375 fn a_setting_nothing_supplied_says_so_rather_than_guessing() {
376 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
377 let text = explain(&resolved, "exclude").expect("declared");
378 assert!(text.contains("exclude is unset"), "{text}");
379 assert_eq!(explain(&resolved, "nonesuch"), None);
382 }
383
384 #[test]
385 fn a_setting_that_is_empty_says_so_rather_than_trailing_off() {
386 let cleared = Fixed(vec![Entry::new(
391 id("exclude"),
392 Value::List(Vec::new()),
393 Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
394 )]);
395 let empty_text = Fixed(vec![Entry::new(
396 id("stash"),
397 Value::from(""),
398 Origin::new(SourceKind::ENV, "HK_STASH"),
399 )]);
400 let resolved =
401 resolve(REGISTRY, Layers::new().then(&cleared).then(&empty_text)).expect("resolves");
402
403 let text = explain(&resolved, "exclude").expect("declared");
404 assert!(text.starts_with("exclude = []\n"), "{text}");
405 assert!(text.contains("set by HK_EXCLUDE"), "{text}");
406 let text = explain(&resolved, "stash").expect("declared");
407 assert!(text.starts_with("stash = \"\"\n"), "{text}");
408
409 let listed = list(&resolved);
411 assert!(listed.contains(&"exclude = []".to_string()), "{listed:?}");
412 assert!(listed.contains(&"stash = \"\"".to_string()), "{listed:?}");
413 for line in &listed {
414 assert_eq!(line, line.trim_end(), "trailing space: {listed:?}");
415 }
416 }
417
418 #[test]
419 fn every_contributor_to_a_union_is_accounted_for() {
420 let env = Fixed(vec![Entry::new(
423 id("exclude"),
424 Value::List(vec![Value::from("target")]),
425 Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
426 )]);
427 let file = Fixed(vec![Entry::new(
428 id("exclude"),
429 Value::List(vec![Value::from("vendor")]),
430 Origin::file("hk.toml#exclude", FileScope::Project),
431 )]);
432 let resolved =
433 resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
434 let text = explain(&resolved, "exclude").expect("declared");
435 assert!(text.contains("exclude = vendor,target"), "{text}");
436 assert!(text.contains("hk.toml#exclude"), "{text}");
437 assert!(text.contains("HK_EXCLUDE"), "{text}");
438 }
439
440 #[test]
441 fn a_value_with_a_newline_in_it_still_occupies_one_line() {
442 let file = Fixed(vec![Entry::new(
446 id("stash"),
447 Value::from("first\nsecond"),
448 Origin::file("hk.toml#stash", FileScope::Project),
449 )]);
450 let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
451
452 let text = explain(&resolved, "stash").expect("declared");
453 assert!(text.contains("stash = first\\nsecond"), "{text}");
454 assert_eq!(
455 text.lines().filter(|l| l.starts_with("stash")).count(),
456 1,
457 "the value should not start a second record:\n{text}"
458 );
459 let lines = list(&resolved);
461 assert!(
462 lines.iter().any(|l| l == "stash = first\\nsecond"),
463 "{lines:?}"
464 );
465 assert_eq!(lines.len(), 4, "{lines:?}");
466 }
467
468 #[test]
469 fn nothing_interpolated_into_a_line_can_leave_it() {
470 struct Odd;
475 impl Layer for Odd {
476 fn source(&self) -> SourceKind {
477 SourceKind::FILE
478 }
479 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
480 let mut out = LayerOutput::new();
481 let odd = Origin::file("hk\n.toml#jobs", FileScope::Project);
483 match ctx.entry_for_key("jobs", "lots\nand lots", odd) {
484 Ok(entry) => out.push(entry),
485 Err(warning) => out.warn(warning),
486 }
487 out.push(Entry::new(
490 id("stash"),
491 Value::from("lower"),
492 Origin::file("also\nodd#stash", FileScope::System),
493 ));
494 out.push(Entry::new(
495 id("stash"),
496 Value::from("first\nsecond"),
497 Origin::file("winner\nodd#stash", FileScope::Project),
500 ));
501 Ok(out)
502 }
503 }
504 let odd = Odd;
505 let resolved = resolve(REGISTRY, Layers::new().then(&odd)).expect("should resolve");
506
507 let lines = warnings(&resolved);
509 assert_eq!(lines.len(), 1, "{lines:?}");
510 assert_eq!(lines[0].lines().count(), 1, "{lines:?}");
511
512 let text = explain(&resolved, "stash").expect("declared");
514 for line in text.lines() {
515 assert!(
516 !line.trim_start().starts_with("odd"),
517 "a path's second half became a record of its own:\n{text}"
518 );
519 }
520 assert!(
521 text.contains("also\\nodd#stash"),
522 "the losing contributor's path should be escaped in place:\n{text}"
523 );
524 assert_eq!(
525 text.matches("also\\nodd#stash").count(),
526 1,
527 "once, in the also-considered list:\n{text}"
528 );
529 assert!(
530 text.contains("winner\\nodd#stash"),
531 "the winner's own path should be escaped too:\n{text}"
532 );
533
534 struct Windows;
537 impl Layer for Windows {
538 fn source(&self) -> SourceKind {
539 SourceKind::FILE
540 }
541 fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
542 Ok(LayerOutput {
543 entries: vec![Entry::new(
544 id("stash"),
545 Value::from(r"C:\Users\me"),
546 Origin::file(r"C:\Users\me\hk.toml#stash", FileScope::Global),
547 )],
548 warnings: Vec::new(),
549 })
550 }
551 }
552 let windows = Windows;
553 let resolved = resolve(REGISTRY, Layers::new().then(&windows)).expect("should resolve");
554 let text = explain(&resolved, "stash").expect("declared");
555 assert!(text.contains(r"C:\Users\me\hk.toml#stash"), "{text}");
556 assert!(!text.contains(r"C:\\Users"), "separators doubled:\n{text}");
557 }
558
559 #[test]
560 fn metadata_stays_on_its_own_line_too() {
561 static PROSE: &[PropMeta] = &[PropMeta {
565 help: Some("One line\n\nAnd a second paragraph."),
566 deprecated: Some("Gone soon.\nReally."),
567 ..PropMeta::new("wordy", Ty::Bool)
568 }];
569 const WORDY: Registry = Registry::new(PROSE);
570 let resolved = resolve(WORDY, Layers::new()).expect("should resolve");
571 let text = explain(&resolved, "wordy").expect("declared");
572 assert!(
573 text.contains("One line\\n\\nAnd a second paragraph."),
574 "{text}"
575 );
576 assert!(text.contains("deprecated: Gone soon.\\nReally."), "{text}");
577 assert_eq!(text.lines().count(), 5, "{text:?}");
580 }
581
582 #[test]
583 fn a_setting_with_bindings_and_no_environment_still_reads_as_a_section() {
584 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
587 let text = explain(&resolved, "stash").expect("declared");
588 assert!(text.contains("\n\n also pkl hk.stash"), "{text:?}");
589 }
590
591 #[test]
592 fn a_listing_leaves_out_what_is_hidden_and_sorts_what_is_left() {
593 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
597 let lines = list(&resolved);
598 assert_eq!(
599 lines,
600 [
601 "exclude is unset",
602 "jobs = 4",
603 "stash is unset",
604 "trusted is unset"
605 ],
606 "sorted, without `internal` or the old name for `jobs`"
607 );
608 }
609
610 #[test]
611 fn a_warning_names_its_place_once() {
612 struct Bad;
617 impl Layer for Bad {
618 fn source(&self) -> SourceKind {
619 SourceKind::FILE
620 }
621 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
622 let mut out = LayerOutput::new();
623 let origin = Origin::file("hk.toml#jobs", FileScope::Project);
624 match ctx.entry_for_key("jobs", "lots", origin) {
625 Ok(entry) => out.push(entry),
626 Err(warning) => out.warn(warning),
627 }
628 Ok(out)
629 }
630 }
631 let bad = Bad;
632 let resolved = resolve(REGISTRY, Layers::new().then(&bad)).expect("should resolve");
633 let lines = warnings(&resolved);
634 assert_eq!(lines.len(), 1, "{lines:?}");
635 assert_eq!(
636 lines[0].matches("hk.toml#jobs").count(),
637 1,
638 "the place should appear once: {lines:?}"
639 );
640 assert!(lines[0].ends_with("(hk.toml#jobs)"), "{lines:?}");
641 }
642
643 #[test]
644 fn warnings_carry_the_place_that_caused_them() {
645 let project = Fixed(vec![Entry::new(
647 id("trusted"),
648 Value::Bool(true),
649 Origin::file("hk.toml#trusted", FileScope::Project),
650 )]);
651 let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve");
652 let lines = warnings(&resolved);
653 assert_eq!(lines.len(), 1, "{lines:?}");
654 assert!(lines[0].starts_with("trusted cannot be set"), "{lines:?}");
655 assert!(lines[0].ends_with("(hk.toml#trusted)"), "{lines:?}");
656 }
657}