1use indexmap::IndexMap;
2use lazy_static::lazy_static;
3use regex::Regex;
4use std::collections::HashSet;
5use std::env;
6use std::fs::File;
7use std::io::{self, BufRead, BufReader, Write};
8use std::path::Path;
9use std::str::FromStr;
10use thiserror::Error;
11use colored::Colorize;
12
13pub type HkConfig = IndexMap<String, HkValue>;
16
17lazy_static! {
18 static ref INTERPOL_RE: Regex = Regex::new(r"\$\{([^}]+)\}").unwrap();
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum HkValue {
24 String(String),
25 Number(f64),
26 Bool(bool),
27 Array(Vec<HkValue>),
28 Map(IndexMap<String, HkValue>),
29}
30
31impl HkValue {
32 pub fn as_string(&self) -> Result<String, HkError> {
33 match self {
34 Self::String(s) => Ok(s.clone()),
35 Self::Number(n) => Ok(n.to_string()),
36 Self::Bool(b) => Ok(b.to_string()),
37 _ => Err(HkError::TypeMismatch {
38 expected: "string".to_string(),
39 found: format!("{:?}", self),
40 }),
41 }
42 }
43
44 pub fn as_number(&self) -> Result<f64, HkError> {
45 if let Self::Number(n) = self {
46 Ok(*n)
47 } else {
48 Err(HkError::TypeMismatch {
49 expected: "number".to_string(),
50 found: format!("{:?}", self),
51 })
52 }
53 }
54
55 pub fn as_bool(&self) -> Result<bool, HkError> {
56 if let Self::Bool(b) = self {
57 Ok(*b)
58 } else {
59 Err(HkError::TypeMismatch {
60 expected: "bool".to_string(),
61 found: format!("{:?}", self),
62 })
63 }
64 }
65
66 pub fn as_array(&self) -> Result<&Vec<HkValue>, HkError> {
67 if let Self::Array(a) = self {
68 Ok(a)
69 } else {
70 Err(HkError::TypeMismatch {
71 expected: "array".to_string(),
72 found: format!("{:?}", self),
73 })
74 }
75 }
76
77 pub fn as_map(&self) -> Result<&IndexMap<String, HkValue>, HkError> {
78 if let Self::Map(m) = self {
79 Ok(m)
80 } else {
81 Err(HkError::TypeMismatch {
82 expected: "map".to_string(),
83 found: format!("{:?}", self),
84 })
85 }
86 }
87}
88
89#[derive(Error, Debug)]
91pub enum HkError {
92 #[error("IO error: {0}")]
93 Io(#[from] io::Error),
94 #[error("Parse error at line {line}, column {column}: {message}")]
95 Parse {
96 line: u32,
97 column: usize,
98 message: String,
99 },
100 #[error("Type mismatch: expected {expected}, found {found}")]
101 TypeMismatch { expected: String, found: String },
102 #[error("Missing field: {0}")]
103 MissingField(String),
104 #[error("Invalid reference: {0}")]
105 InvalidReference(String),
106 #[error("Cyclic reference detected: {0}")]
107 CyclicReference(String),
108 #[error("Key conflict: {0}")]
109 KeyConflict(String),
110}
111
112impl HkError {
113 pub fn pretty_print(&self, source: &str) {
114 match self {
115 Self::Parse { line, column, message } => {
116 eprintln!("{} {}", "error:".red().bold(), "parse error".red().bold());
117 eprintln!(" {} at {}:{}", "→".red(), line, column);
118 if let Some(line_content) = source.lines().nth((*line - 1) as usize) {
119 eprintln!("\n {}", line_content);
120 eprintln!(" {}{}", " ".repeat(*column), "^".red().bold());
121 eprintln!(" {}", message.red());
122 } else {
123 eprintln!(" {}", message.red());
124 }
125
126 if message.contains("tag \"=>\"") {
127 eprintln!("\n{} {}", "Hint:".yellow().bold(), "Try: key => value".cyan());
128 } else if message.contains("tag \"[\"") {
129 eprintln!("\n{} {}", "Hint:".yellow().bold(), "Sections must start with [name]".cyan());
130 } else if message.contains("take_while1") {
131 eprintln!("\n{} {}", "Hint:".yellow().bold(), "Keys can only contain letters, digits, '_', '-', '.'".cyan());
132 } else if message.contains("Inconsistent nesting level") {
133 eprintln!("\n{} {}", "Hint:".yellow().bold(), "Each nesting level must add exactly one dash: '->', then '-->', then '--->', etc. Don't skip a level.".cyan());
134 }
135 }
136 Self::TypeMismatch { expected, found } => {
137 eprintln!("{} {}", "error:".red().bold(), "type mismatch".red().bold());
138 eprintln!(" expected {}, got {}", expected.cyan(), found.red());
139 }
140 Self::InvalidReference(ref_var) => {
141 eprintln!("{} {}", "error:".red().bold(), "invalid reference".red().bold());
142 eprintln!(" {}", ref_var.red());
143 eprintln!("\n{} {}", "Hint:".yellow().bold(), "Check if the referenced key exists and is accessible".cyan());
144 }
145 Self::CyclicReference(path) => {
146 eprintln!("{} {}", "error:".red().bold(), "cyclic reference".red().bold());
147 eprintln!(" {}", path.red());
148 }
149 Self::KeyConflict(key) => {
150 eprintln!("{} {}", "error:".red().bold(), "key conflict".red().bold());
151 eprintln!(" Duplicate key '{}' in nested structure", key.red());
152 }
153 _ => eprintln!("{}", self.to_string().red()),
154 }
155 }
156}
157
158pub fn parse_hk(input: &str) -> Result<HkConfig, HkError> {
160 let lines: Vec<&str> = input.lines().collect();
161 let mut config = IndexMap::new();
162 let mut i = 0;
163
164 while i < lines.len() {
165 let line = lines[i].trim_start();
166 if line.is_empty() || line.starts_with('!') {
167 i += 1;
168 continue;
169 }
170
171 if line.starts_with('[') {
172 let close = line.find(']').ok_or_else(|| HkError::Parse {
173 line: (i + 1) as u32,
174 column: line.find('[').unwrap() + 1,
175 message: "Unclosed section header".to_string(),
176 })?;
177 let section_name = line[1..close].trim();
178 if section_name.is_empty() {
179 return Err(HkError::Parse {
180 line: (i + 1) as u32,
181 column: close + 1,
182 message: "Empty section name".to_string(),
183 });
184 }
185
186 let mut end = i + 1;
188 while end < lines.len() {
189 let next_line = lines[end].trim_start();
190 if next_line.starts_with('[') {
191 break;
192 }
193 end += 1;
194 }
195
196 let section_lines = &lines[i + 1..end];
197 let map = parse_map(1, section_lines, i + 1)?;
198 config.insert(section_name.to_string(), HkValue::Map(map));
199 i = end;
200 } else {
201 return Err(HkError::Parse {
202 line: (i + 1) as u32,
203 column: 1,
204 message: "Expected section header".to_string(),
205 });
206 }
207 }
208
209 Ok(config)
210}
211
212fn parse_map(level: usize, lines: &[&str], start_line: usize) -> Result<IndexMap<String, HkValue>, HkError> {
216 let mut map = IndexMap::new();
217 let mut i = 0;
218
219 while i < lines.len() {
220 let line = lines[i];
221 let trimmed = line.trim_start();
222 if trimmed.is_empty() || trimmed.starts_with('!') {
223 i += 1;
224 continue;
225 }
226
227 let dash_count = trimmed.chars().take_while(|c| *c == '-').count();
229 if dash_count == 0 {
230 return Err(HkError::Parse {
231 line: (start_line + i) as u32,
232 column: 1,
233 message: "Expected key or map header".to_string(),
234 });
235 }
236 if dash_count < level {
237 break;
239 }
240 if dash_count > level {
241 return Err(HkError::Parse {
248 line: (start_line + i) as u32,
249 column: 1,
250 message: format!(
251 "Inconsistent nesting level: expected {} dash(es) (\"{}\") at this depth, found {} (\"{}\"). Nesting must increase by exactly one dash per level.",
252 level,
253 "-".repeat(level),
254 dash_count,
255 "-".repeat(dash_count)
256 ),
257 });
258 }
259
260 let after_dashes = &trimmed[dash_count..];
262 let rest = after_dashes.trim_start();
263 if !rest.starts_with('>') {
264 return Err(HkError::Parse {
265 line: (start_line + i) as u32,
266 column: dash_count + 1,
267 message: "Expected '>' after dashes".to_string(),
268 });
269 }
270 let after_gt = &rest[1..].trim_start();
271 if after_gt.is_empty() {
272 return Err(HkError::Parse {
273 line: (start_line + i) as u32,
274 column: dash_count + 1,
275 message: "Missing key after '>'".to_string(),
276 });
277 }
278
279 if let Some(arrow_pos) = after_gt.find("=>") {
281 let key = after_gt[..arrow_pos].trim();
282 let value_part = after_gt[arrow_pos + 2..].trim();
283 let key = unquote_key(key);
284 if key.is_empty() {
285 return Err(HkError::Parse {
286 line: (start_line + i) as u32,
287 column: dash_count + 1,
288 message: "Empty key".to_string(),
289 });
290 }
291 let value = parse_value(value_part, start_line + i, arrow_pos + dash_count + 2)?;
292 insert_key(&mut map, &key, value)?;
293 i += 1;
294 } else {
295 let key = after_gt.trim();
297 let key = unquote_key(key);
298 if key.is_empty() {
299 return Err(HkError::Parse {
300 line: (start_line + i) as u32,
301 column: dash_count + 1,
302 message: "Empty map key".to_string(),
303 });
304 }
305
306 let next_level = level + 1;
308 let mut j = i + 1;
309 while j < lines.len() {
310 let sub_line = lines[j];
311 let sub_trimmed = sub_line.trim_start();
312 if sub_trimmed.is_empty() || sub_trimmed.starts_with('!') {
313 j += 1;
314 continue;
315 }
316 let sub_dash_count = sub_trimmed.chars().take_while(|c| *c == '-').count();
317 if sub_dash_count < next_level {
318 break;
319 }
320 j += 1;
321 }
322
323 let sub_lines = &lines[i + 1..j];
324 let sub_map = parse_map(next_level, sub_lines, start_line + i + 1)?;
325 insert_key(&mut map, &key, HkValue::Map(sub_map))?;
326 i = j;
327 }
328 }
329
330 Ok(map)
331}
332
333fn insert_key(map: &mut IndexMap<String, HkValue>, key: &str, value: HkValue) -> Result<(), HkError> {
336 if key.contains('.') && !key.starts_with('.') && !key.ends_with('.') {
338 let parts: Vec<&str> = key.split('.').collect();
339 insert_nested(map, parts, value)
340 } else {
341 if map.contains_key(key) {
343 return Err(HkError::KeyConflict(key.to_string()));
344 }
345 map.insert(key.to_string(), value);
346 Ok(())
347 }
348}
349
350fn insert_nested(map: &mut IndexMap<String, HkValue>, keys: Vec<&str>, value: HkValue) -> Result<(), HkError> {
352 let mut current = map;
353 for key in &keys[0..keys.len() - 1] {
354 let entry = current
355 .entry(key.to_string())
356 .or_insert(HkValue::Map(IndexMap::new()));
357 if let HkValue::Map(submap) = entry {
358 current = submap;
359 } else {
360 return Err(HkError::KeyConflict(key.to_string()));
361 }
362 }
363 if let Some(last_key) = keys.last() {
364 current.insert(last_key.to_string(), value);
365 }
366 Ok(())
367}
368
369fn unquote_key(s: &str) -> String {
371 let s = s.trim();
372 if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
373 let inner = &s[1..s.len() - 1];
374 inner.replace("\\\"", "\"")
375 } else {
376 s.to_string()
377 }
378}
379
380fn parse_value(s: &str, line: usize, column: usize) -> Result<HkValue, HkError> {
381 let s = s.trim();
382 if s.is_empty() {
383 return Err(HkError::Parse {
384 line: line as u32,
385 column,
386 message: "Empty value".to_string(),
387 });
388 }
389
390 if s.starts_with('[') && s.ends_with(']') {
392 let inner = &s[1..s.len() - 1];
393 let mut items = Vec::new();
394 let mut current = String::new();
395 let mut in_quotes = false;
396 let mut escape = false;
397 for c in inner.chars() {
398 if escape {
399 current.push(c);
400 escape = false;
401 continue;
402 }
403 match c {
404 '\\' => escape = true,
405 '"' => in_quotes = !in_quotes,
406 ',' if !in_quotes => {
407 if !current.trim().is_empty() {
408 let item = parse_simple_value(current.trim(), line, column)?;
409 items.push(item);
410 current.clear();
411 }
412 }
413 _ => current.push(c),
414 }
415 }
416 if !current.trim().is_empty() {
417 let item = parse_simple_value(current.trim(), line, column)?;
418 items.push(item);
419 }
420 Ok(HkValue::Array(items))
421 } else {
422 parse_simple_value(s, line, column)
423 }
424}
425
426fn parse_simple_value(s: &str, line: usize, column: usize) -> Result<HkValue, HkError> {
427 let s = s.trim();
428 if s.is_empty() {
429 return Err(HkError::Parse {
430 line: line as u32,
431 column,
432 message: "Empty value".to_string(),
433 });
434 }
435
436 if s.eq_ignore_ascii_case("true") {
438 return Ok(HkValue::Bool(true));
439 }
440 if s.eq_ignore_ascii_case("false") {
441 return Ok(HkValue::Bool(false));
442 }
443
444 if let Ok(n) = f64::from_str(s) {
446 return Ok(HkValue::Number(n));
447 }
448
449 if s.starts_with('"') && s.ends_with('"') {
451 let inner = &s[1..s.len() - 1];
452 let mut result = String::new();
453 let mut chars = inner.chars();
454 while let Some(c) = chars.next() {
455 if c == '\\' {
456 if let Some(next) = chars.next() {
457 match next {
458 'n' => result.push('\n'),
459 'r' => result.push('\r'),
460 't' => result.push('\t'),
461 '"' => result.push('"'),
462 '\\' => result.push('\\'),
463 _ => result.push(next),
464 }
465 }
466 } else {
467 result.push(c);
468 }
469 }
470 Ok(HkValue::String(result))
471 } else {
472 Ok(HkValue::String(s.to_string()))
474 }
475}
476
477pub fn load_hk_file<P: AsRef<Path>>(path: P) -> Result<HkConfig, HkError> {
479 let file = File::open(path)?;
480 let reader = BufReader::new(file);
481 let mut contents = String::new();
482 for line in reader.lines() {
483 let line = line?;
484 contents.push_str(&line);
485 contents.push('\n');
486 }
487 parse_hk(&contents)
488}
489
490pub fn resolve_interpolations(config: &mut HkConfig) -> Result<(), HkError> {
492 let context = config.clone();
493 let mut resolved = HashSet::new();
494 let mut resolving = Vec::new();
495 for (section, value) in config.iter_mut() {
496 if let HkValue::Map(map) = value {
497 resolve_map(map, &context, &mut resolved, &mut resolving, &format!("{}", section))?;
498 }
499 }
500 Ok(())
501}
502
503fn resolve_map(
504 map: &mut IndexMap<String, HkValue>,
505 top: &HkConfig,
506 resolved: &mut HashSet<String>,
507 resolving: &mut Vec<String>,
508 path: &str,
509) -> Result<(), HkError> {
510 for (key, v) in map.iter_mut() {
511 let new_path = format!("{}.{}", path, key);
512 if resolved.contains(&new_path) {
513 continue;
514 }
515 resolving.push(new_path.clone());
516 resolve_value(v, top, resolved, resolving, &new_path)?;
517 resolving.pop();
518 resolved.insert(new_path);
519 }
520 Ok(())
521}
522
523fn resolve_value(
524 v: &mut HkValue,
525 top: &HkConfig,
526 resolved: &mut HashSet<String>,
527 resolving: &mut Vec<String>,
528 path: &str,
529) -> Result<(), HkError> {
530 match v {
531 HkValue::String(s) => {
532 let mut new_s = String::new();
533 let mut last = 0;
534 for cap in INTERPOL_RE.captures_iter(s) {
535 let m = cap.get(0).unwrap();
536 new_s.push_str(&s[last..m.start()]);
537 let var = &cap[1];
538 let repl = if var.starts_with("env:") {
539 env::var(&var[4..]).unwrap_or_default()
540 } else {
541 if resolving.contains(&var.to_string()) {
543 return Err(HkError::CyclicReference(var.to_string()));
544 }
545 resolve_reference(var, top, resolved, resolving)?
546 };
547 new_s.push_str(&repl);
548 last = m.end();
549 }
550 new_s.push_str(&s[last..]);
551 *s = new_s;
552 }
553 HkValue::Array(a) => {
554 for (i, item) in a.iter_mut().enumerate() {
555 resolve_value(item, top, resolved, resolving, &format!("{}[{}]", path, i))?;
556 }
557 }
558 HkValue::Map(m) => {
559 resolve_map(m, top, resolved, resolving, path)?;
560 }
561 _ => {}
562 }
563 Ok(())
564}
565
566fn resolve_reference(
567 path: &str,
568 top: &HkConfig,
569 resolved: &mut HashSet<String>,
570 resolving: &mut Vec<String>,
571) -> Result<String, HkError> {
572 if resolving.contains(&path.to_string()) {
574 return Err(HkError::CyclicReference(path.to_string()));
575 }
576
577 let raw_value = get_value_by_path(path, top).ok_or_else(|| HkError::InvalidReference(path.to_string()))?;
579 let mut cloned_value = raw_value.clone();
581
582 resolving.push(path.to_string());
584
585 resolve_value(&mut cloned_value, top, resolved, resolving, path)?;
587
588 resolving.pop();
590
591 cloned_value.as_string()
593}
594
595fn get_value_by_path<'a>(path: &str, config: &'a HkConfig) -> Option<&'a HkValue> {
596 let bracket_re = Regex::new(r"([^\[\].]+)(?:\[(\d+)\])?").unwrap();
597 let mut parts = Vec::new();
598 for cap in bracket_re.captures_iter(path) {
599 let key = cap.get(1).map(|m| m.as_str()).unwrap();
600 let idx = cap.get(2).map(|m| m.as_str().parse::<usize>().ok());
601 parts.push((key, idx.flatten()));
602 }
603
604 if parts.is_empty() {
605 return None;
606 }
607
608 let (first_key, _) = parts[0];
609 let mut current_value: Option<&'a HkValue> = config.get(first_key);
610 for (key, idx) in parts.iter().skip(1) {
611 match current_value {
612 Some(HkValue::Map(map)) => {
613 current_value = map.get(*key);
614 }
615 Some(HkValue::Array(arr)) if idx.is_some() => {
616 if let Some(i) = idx {
617 if *i < arr.len() {
618 current_value = Some(&arr[*i]);
619 continue;
620 } else {
621 return None;
622 }
623 } else {
624 return None;
625 }
626 }
627 _ => return None,
628 }
629 if let Some(idx) = idx {
630 if let Some(HkValue::Array(arr)) = current_value {
631 if *idx < arr.len() {
632 current_value = Some(&arr[*idx]);
633 } else {
634 return None;
635 }
636 } else {
637 return None;
638 }
639 }
640 }
641 current_value
642}
643
644pub fn serialize_hk(config: &HkConfig) -> String {
646 let mut output = String::new();
647 for (section, value) in config.iter() {
648 output.push_str(&format!("[{}]\n", section));
649 if let HkValue::Map(map) = value {
650 serialize_map(map, 1, &mut output);
651 }
652 output.push('\n');
653 }
654 output.trim_end().to_string()
655}
656
657fn serialize_map(map: &IndexMap<String, HkValue>, level: usize, output: &mut String) {
658 let prefix = "-".repeat(level) + " > ";
659 for (key, value) in map.iter() {
660 match value {
661 HkValue::Map(submap) => {
662 output.push_str(&format!("{}{}\n", prefix, key));
663 serialize_map(submap, level + 1, output);
664 }
665 _ => {
666 let val = serialize_value(value);
667 output.push_str(&format!("{}{} => {}\n", prefix, key, val));
668 }
669 }
670 }
671}
672
673fn serialize_value(value: &HkValue) -> String {
674 match value {
675 HkValue::String(s) => {
676 if s.is_empty() || s.contains(',') || s.contains(' ') || s.contains(']') || s.contains('"') || s.contains('\n') {
677 format!("\"{}\"", s.replace("\"", "\\\""))
678 } else {
679 s.clone()
680 }
681 }
682 HkValue::Number(n) => n.to_string(),
683 HkValue::Bool(b) => if *b { "true".to_string() } else { "false".to_string() },
684 HkValue::Array(a) => format!(
685 "[{}]",
686 a.iter()
687 .map(serialize_value)
688 .collect::<Vec<_>>()
689 .join(", ")
690 ),
691 HkValue::Map(_) => "<map>".to_string(),
692 }
693}
694
695pub fn write_hk_file<P: AsRef<Path>>(path: P, config: &HkConfig) -> io::Result<()> {
696 let mut file = File::create(path)?;
697 file.write_all(serialize_hk(config).as_bytes())
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use pretty_assertions::assert_eq;
704
705 #[test]
706 fn test_parse_libraries_repo() {
707 let input = r#"
708! Repozytorium bibliotek dla Hacker Lang
709
710[libraries]
711-> obsidian
712--> version => 0.2
713--> description => Biblioteka inspirowana zenity.
714--> authors => ["HackerOS Team <hackeros068@gmail.com>"]
715--> so-download => https://github.com/Bytes-Repository/obsidian-lib/releases/download/v0.2/libobsidian_lib.so
716--> .hl-download => https://github.com/Bytes-Repository/obsidian-lib/blob/main/obsidian.hl
717
718-> yuy
719--> version => 0.2
720--> description => Twórz ładne interfejsy cli
721"#;
722 let result = parse_hk(input).expect("Failed to parse libraries file");
723 assert!(result.contains_key("libraries"));
724 let libraries = result["libraries"].as_map().unwrap();
725 assert!(libraries.contains_key("obsidian"));
726 let obsidian = libraries["obsidian"].as_map().unwrap();
727 assert_eq!(obsidian["version"].as_number().unwrap(), 0.2);
728 assert_eq!(obsidian["description"].as_string().unwrap(), "Biblioteka inspirowana zenity.");
729 assert!(obsidian.contains_key("so-download"));
730 assert!(obsidian.contains_key(".hl-download"));
731 assert_eq!(
732 obsidian[".hl-download"].as_string().unwrap(),
733 "https://github.com/Bytes-Repository/obsidian-lib/blob/main/obsidian.hl"
734 );
735
736 assert!(libraries.contains_key("yuy"));
737 let yuy = libraries["yuy"].as_map().unwrap();
738 assert_eq!(yuy["version"].as_number().unwrap(), 0.2);
739 }
740
741 #[test]
742 fn test_parse_hk_with_comments_and_types() {
743 let input = r#"
744 ! Globalne informacje o projekcie
745 [metadata]
746 -> name => Hacker Lang
747 -> version => 1.5
748 -> list => [1, 2.5, true, "four"]
749 "#;
750 let result = parse_hk(input).unwrap();
751 assert!(result.contains_key("metadata"));
752 let metadata = result["metadata"].as_map().unwrap();
753 assert_eq!(metadata["name"].as_string().unwrap(), "Hacker Lang");
754 assert_eq!(metadata["version"].as_number().unwrap(), 1.5);
755 let list = metadata["list"].as_array().unwrap();
756 assert_eq!(list.len(), 4);
757 }
758
759 #[test]
760 fn test_edge_cases() {
761 let input = "[empty]\n";
763 let config = parse_hk(input).unwrap();
764 assert!(config.contains_key("empty"));
765 assert_eq!(config["empty"].as_map().unwrap().len(), 0);
766
767 let input = "[comments]\n! comment\n! another\n";
769 let config = parse_hk(input).unwrap();
770 assert!(config.contains_key("comments"));
771 assert_eq!(config["comments"].as_map().unwrap().len(), 0);
772
773 let input = r#"
775[config]
776-> a.b.c => 42
777"#;
778 let config = parse_hk(input).unwrap();
779 let a = config["config"].as_map().unwrap().get("a").unwrap().as_map().unwrap();
780 let b = a.get("b").unwrap().as_map().unwrap();
781 let c = b.get("c").unwrap().as_number().unwrap();
782 assert_eq!(c, 42.0);
783 }
784
785 #[test]
786 fn test_array_reference() {
787 let input = r#"
788[data]
789-> numbers => [10, 20, 30]
790-> first => ${data.numbers[0]}
791"#;
792 let mut config = parse_hk(input).unwrap();
793 resolve_interpolations(&mut config).unwrap();
794 let first = config["data"].as_map().unwrap()["first"].as_string().unwrap();
795 assert_eq!(first, "10");
796 }
797
798 #[test]
799 fn test_cyclic_reference_detection() {
800 let input = r#"
801[a]
802-> b => ${a.c}
803-> c => ${a.b}
804"#;
805 let mut config = parse_hk(input).unwrap();
806 let err = resolve_interpolations(&mut config).unwrap_err();
807 match err {
808 HkError::CyclicReference(path) => {
809 assert!(path.contains("a.b") || path.contains("a.c"));
810 }
811 _ => panic!("Expected cyclic reference error, got {:?}", err),
812 }
813 }
814
815 #[test]
816 fn test_key_conflict() {
817 let input = r#"
818[conflict]
819-> a => 1
820-> a.b => 2
821"#;
822 let result = parse_hk(input);
823 assert!(result.is_err());
824 }
825
826 #[test]
827 fn test_invalid_reference() {
828 let input = r#"
829[a]
830-> b => ${a.missing}
831"#;
832 let mut config = parse_hk(input).unwrap();
833 let err = resolve_interpolations(&mut config).unwrap_err();
834 match err {
835 HkError::InvalidReference(var) => {
836 assert_eq!(var, "a.missing");
837 }
838 _ => panic!("Expected invalid reference error"),
839 }
840 }
841
842 #[test]
843 fn test_serialize_roundtrip() {
844 let input = r#"
845[test]
846-> key => value
847-> array => [1, "two", true]
848-> nested
849--> sub => 42
850"#;
851 let config = parse_hk(input).unwrap();
852 let serialized = serialize_hk(&config);
853 let parsed_again = parse_hk(&serialized).unwrap();
854 assert_eq!(config, parsed_again);
855 }
856
857 #[test]
858 fn test_skipped_nesting_level_is_rejected() {
859 let input = r#"
863[global]
864-> features
865--> logging
866----> level => debug
867----> file => /var/log/app.log
868"#;
869 let result = parse_hk(input);
870 assert!(result.is_err(), "Expected a parse error for skipped nesting level, got Ok");
871 match result.unwrap_err() {
872 HkError::Parse { message, .. } => {
873 assert!(message.contains("Inconsistent nesting level"), "unexpected message: {message}");
874 }
875 other => panic!("Expected HkError::Parse, got {:?}", other),
876 }
877 }
878
879 #[test]
880 fn test_skipped_nesting_level_in_dotted_key_header_is_rejected() {
881 let input = r#"
884[deep]
885-> array.of.objects
886---> item => value
887"#;
888 let result = parse_hk(input);
889 assert!(result.is_err(), "Expected a parse error for skipped nesting level, got Ok");
890 }
891
892 #[test]
893 fn test_correct_incremental_nesting_still_works() {
894 let input = r#"
897[global]
898-> features
899--> logging
900---> level => debug
901---> file => /var/log/app.log
902"#;
903 let config = parse_hk(input).unwrap();
904 let logging = config["global"]
905 .as_map().unwrap()["features"]
906 .as_map().unwrap()["logging"]
907 .as_map().unwrap();
908 assert_eq!(logging["level"].as_string().unwrap(), "debug");
909 assert_eq!(logging["file"].as_string().unwrap(), "/var/log/app.log");
910 }
911
912 #[test]
913 fn test_shallower_return_to_caller_still_works() {
914 let input = r#"
917[a]
918-> x
919--> y => 1
920-> z => 2
921"#;
922 let config = parse_hk(input).unwrap();
923 let a = config["a"].as_map().unwrap();
924 assert_eq!(a["x"].as_map().unwrap()["y"].as_number().unwrap(), 1.0);
925 assert_eq!(a["z"].as_number().unwrap(), 2.0);
926 }
927
928 #[test]
929 fn test_empty_string_roundtrip() {
930 let input = r#"
933[a]
934-> key => ""
935"#;
936 let config = parse_hk(input).unwrap();
937 assert_eq!(config["a"].as_map().unwrap()["key"].as_string().unwrap(), "");
938 let serialized = serialize_hk(&config);
939 assert!(serialized.contains("\"\""), "expected empty string to serialize as \"\", got: {serialized}");
940 let parsed_again = parse_hk(&serialized).unwrap();
941 assert_eq!(config, parsed_again);
942 }
943}