///|
fn resolve_project_path(root : String, path : String) -> String {
  let normalized = normalize_path(path)
  if normalized.has_prefix("/") || normalized.contains(":/") {
    normalized
  } else {
    join_path(root, normalized)
  }
}

///|
fn file_signature(path : String) -> String raise MoonforgeError {
  let normalized = normalize_path(path)
  if !@fs.path_exists(normalized) {
    "missing"
  } else {
    let is_dir = @fs.is_dir(normalized) catch {
      @fs.IOError(message) =>
        execution_error("failed to inspect '\{normalized}': \{message}")
    }
    if is_dir {
      let entries = @fs.read_dir(normalized) catch {
        @fs.IOError(message) =>
          execution_error(
            "failed to read directory '\{normalized}': \{message}",
          )
      }
      entries.sort()
      let ctx = @crypto.SHA256::new()
      ctx.update(@utf8.encode("dir:\{normalized}\n"))
      for entry in entries {
        let child = join_path(normalized, entry)
        ctx.update(@utf8.encode(entry))
        ctx.update(@utf8.encode(":"))
        ctx.update(@utf8.encode(file_signature(child)))
        ctx.update(@utf8.encode("\n"))
      }
      @crypto.bytes_to_hex_string(ctx.finalize())
    } else {
      let content = @fs.read_file_to_bytes(normalized) catch {
        @fs.IOError(message) =>
          execution_error("failed to read file '\{normalized}': \{message}")
      }
      hash_bytes(content)
    }
  }
}

///|
fn fingerprint_paths(
  root : String,
  paths : Array[String],
) -> String raise MoonforgeError {
  let normalized = paths.map(fn(path) { resolve_project_path(root, path) })
  let parts = []
  for path in normalized {
    parts.push(path)
    parts.push(file_signature(path))
  }
  hash_string(parts)
}

///|
fn outputs_exist(root : String, task : Task) -> Bool {
  if task.outputs.is_empty() {
    true
  } else {
    for output in task.outputs {
      if !@fs.path_exists(resolve_project_path(root, output)) {
        return false
      }
    }
    true
  }
}