use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::time::Duration;
use serde_json::Value;
use tempfile::TempDir;
use url::Url;
use crate::cli::PackageScaffoldOpenapiArgs;
use crate::commands::run::{execute_run, CliLlmMockMode, RunProfileOptions};
use crate::commands::scaffold_common::{
harn_identifier, harn_string_literal, pascal_identifier_from_snake, validate_harn_identifier,
write_bytes, write_file,
};
use crate::package::{
current_harn_range_example, generate_package_docs_impl, toml_string_literal,
validate_package_alias, PackageError,
};
const DEFAULT_HARN_OPENAPI_GIT: &str = "https://github.com/burin-labs/harn-openapi";
const DEFAULT_HARN_OPENAPI_BRANCH: &str = "main";
const DEFAULT_SMOKE_BASE_URL: &str = "https://api.example.test";
const MAX_SPEC_BYTES: u64 = 16 * 1024 * 1024;
pub(crate) async fn run_openapi(args: &PackageScaffoldOpenapiArgs) -> Result<(), PackageError> {
scaffold_openapi_package(args).await
}
async fn scaffold_openapi_package(args: &PackageScaffoldOpenapiArgs) -> Result<(), PackageError> {
validate_package_alias(&args.name)?;
let module_name = args
.module_name
.clone()
.unwrap_or(harn_identifier(&args.name)?);
validate_harn_identifier(&module_name, "--module-name")?;
let client_name = args.client_name.clone().unwrap_or_else(|| {
let base = pascal_identifier_from_snake(&module_name);
if base.ends_with("Client") {
base
} else {
format!("{base}Client")
}
});
validate_harn_identifier(&client_name, "--client-name")?;
let dest = args
.out
.clone()
.unwrap_or_else(|| PathBuf::from(&args.name));
prepare_destination(&dest, args.force)?;
let spec = load_spec(&args.spec).await?;
let default_base_url = args
.default_base_url
.clone()
.or_else(|| first_server_url(&spec.value))
.unwrap_or_else(|| DEFAULT_SMOKE_BASE_URL.to_string());
let smoke = find_smoke_operation(&spec.value);
let generator = resolve_harn_openapi(args)?;
for (relative_path, content) in openapi_template_files(
args,
&module_name,
&client_name,
&spec,
&default_base_url,
smoke.as_ref(),
)? {
write_file(&dest, relative_path, &content)?;
}
write_bytes(&dest, &spec.relative_path, spec.text.as_bytes())?;
let generated_path = dest.join("src/lib.harn");
generate_sdk_source(
&generator.lib_import_path,
&dest.join(&spec.relative_path),
&generated_path,
&module_name,
&client_name,
&default_base_url,
)
.await?;
generate_package_docs_impl(Some(&dest), None, false)?;
println!(
"Scaffolded OpenAPI SDK package '{}' at {}",
args.name,
dest.display()
);
println!(" cd {}", dest.display());
println!(" harn install");
println!(" harn check src/lib.harn");
println!(" harn test tests/");
println!(" harn package check");
println!(" harn package docs --check");
println!(" harn package pack --dry-run");
Ok(())
}
fn prepare_destination(dest: &Path, force: bool) -> Result<(), PackageError> {
if dest.exists() {
if !force {
return Err(format!(
"{} already exists. Pass --force to overwrite generated files.",
dest.display()
)
.into());
}
if !dest.is_dir() {
return Err(format!("{} exists and is not a directory.", dest.display()).into());
}
return Ok(());
}
fs::create_dir_all(dest)
.map_err(|error| format!("failed to create {}: {error}", dest.display()).into())
}
struct LoadedSpec {
relative_path: String,
source_label: String,
text: String,
value: Value,
}
async fn load_spec(spec: &str) -> Result<LoadedSpec, PackageError> {
let (source_label, bytes) = match Url::parse(spec) {
Ok(url) => match url.scheme() {
"http" | "https" => fetch_spec_url(&url).await?,
"file" => {
let path = url.to_file_path().map_err(|_| {
format!("OpenAPI spec file URL {url} does not resolve to a local path")
})?;
read_spec_path(&path)?
}
scheme => {
return Err(format!(
"unsupported OpenAPI spec URL scheme {scheme:?}; use a file path or http(s) URL"
)
.into());
}
},
Err(_) => read_spec_path(&expand_tilde(spec))?,
};
if bytes.len() as u64 > MAX_SPEC_BYTES {
return Err(format!(
"OpenAPI spec {} is {} bytes; maximum supported size is {} bytes",
source_label,
bytes.len(),
MAX_SPEC_BYTES
)
.into());
}
let text = String::from_utf8(bytes)
.map_err(|error| format!("OpenAPI spec {source_label} is not UTF-8: {error}"))?;
let (value, is_json) = parse_spec_value(&text, &source_label)?;
validate_openapi_version(&value, &source_label)?;
let extension = if is_json { "json" } else { "yaml" };
Ok(LoadedSpec {
relative_path: format!("openapi/openapi.{extension}"),
source_label,
text: ensure_trailing_newline(text),
value,
})
}
async fn fetch_spec_url(url: &Url) -> Result<(String, Vec<u8>), PackageError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| format!("failed to initialize HTTP client: {error}"))?;
let mut response = client
.get(url.clone())
.send()
.await
.map_err(|error| format!("failed to fetch OpenAPI spec {url}: {error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("failed to fetch OpenAPI spec {url}: HTTP {status}").into());
}
if response
.content_length()
.is_some_and(|length| length > MAX_SPEC_BYTES)
{
return Err(format!(
"OpenAPI spec {url} is larger than the {} byte limit",
MAX_SPEC_BYTES
)
.into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("failed to read OpenAPI spec {url}: {error}"))?
{
let next_len = bytes.len().saturating_add(chunk.len());
if next_len > MAX_SPEC_BYTES as usize {
return Err(format!(
"OpenAPI spec {url} is larger than the {} byte limit",
MAX_SPEC_BYTES
)
.into());
}
bytes.extend_from_slice(&chunk);
}
Ok((url.to_string(), bytes))
}
fn read_spec_path(path: &Path) -> Result<(String, Vec<u8>), PackageError> {
let metadata = fs::metadata(path)
.map_err(|error| format!("failed to stat OpenAPI spec {}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!("OpenAPI spec {} is not a file", path.display()).into());
}
if metadata.len() > MAX_SPEC_BYTES {
return Err(format!(
"OpenAPI spec {} is {} bytes; maximum supported size is {} bytes",
path.display(),
metadata.len(),
MAX_SPEC_BYTES
)
.into());
}
let bytes = fs::read(path)
.map_err(|error| format!("failed to read OpenAPI spec {}: {error}", path.display()))?;
Ok((path.display().to_string(), bytes))
}
fn parse_spec_value(text: &str, source_label: &str) -> Result<(Value, bool), PackageError> {
match serde_json::from_str::<Value>(text) {
Ok(value) => Ok((value, true)),
Err(json_error) => match serde_yaml::from_str::<Value>(text) {
Ok(value) => Ok((value, false)),
Err(yaml_error) => Err(format!(
"failed to parse OpenAPI spec {source_label} as JSON ({json_error}) or YAML ({yaml_error})"
)
.into()),
},
}
}
fn validate_openapi_version(value: &Value, source_label: &str) -> Result<(), PackageError> {
let version = value
.get("openapi")
.and_then(Value::as_str)
.ok_or_else(|| format!("OpenAPI spec {source_label} is missing top-level `openapi`"))?;
if !version.starts_with("3.1.") {
return Err(
format!("OpenAPI spec {source_label} must be OpenAPI 3.1.x; got {version}").into(),
);
}
Ok(())
}
fn ensure_trailing_newline(mut text: String) -> String {
if !text.ends_with('\n') {
text.push('\n');
}
text
}
struct ResolvedHarnOpenapi {
lib_import_path: String,
_checkout: Option<TempDir>,
}
fn resolve_harn_openapi(
args: &PackageScaffoldOpenapiArgs,
) -> Result<ResolvedHarnOpenapi, PackageError> {
if let Some(path) = args.harn_openapi_path.as_deref() {
return resolve_local_harn_openapi(path).map(|lib_import_path| ResolvedHarnOpenapi {
lib_import_path,
_checkout: None,
});
}
for candidate in harn_openapi_candidates(args) {
if let Ok(lib_import_path) = resolve_local_harn_openapi(&candidate) {
return Ok(ResolvedHarnOpenapi {
lib_import_path,
_checkout: None,
});
}
}
clone_harn_openapi(args)
}
fn harn_openapi_candidates(args: &PackageScaffoldOpenapiArgs) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(git) = args.harn_openapi_git.as_deref() {
let path = expand_tilde(git);
if path.exists() {
candidates.push(path);
}
}
if let Ok(raw) = std::env::var("HARN_OPENAPI_PATH") {
if !raw.trim().is_empty() {
candidates.push(expand_tilde(raw.trim()));
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("../harn-openapi"));
candidates.push(cwd.join("harn-openapi"));
}
if let Some(home) = std::env::var_os("HOME") {
candidates.push(PathBuf::from(home).join("projects/harn-openapi"));
}
candidates
}
fn resolve_local_harn_openapi(path: &Path) -> Result<String, PackageError> {
let root = expand_tilde_path(path);
let lib = root.join("src/lib.harn");
if !lib.is_file() {
return Err(format!(
"{} is not a harn-openapi checkout with src/lib.harn",
root.display()
)
.into());
}
let import_path = lib.with_extension("");
Ok(import_path.display().to_string())
}
fn clone_harn_openapi(
args: &PackageScaffoldOpenapiArgs,
) -> Result<ResolvedHarnOpenapi, PackageError> {
let git = args
.harn_openapi_git
.as_deref()
.unwrap_or(DEFAULT_HARN_OPENAPI_GIT);
let tmp = tempfile::tempdir()
.map_err(|error| format!("failed to create harn-openapi temp checkout: {error}"))?;
let checkout = tmp.path().join("harn-openapi");
let mut command = process::Command::new("git");
command.arg("clone");
if args.harn_openapi_rev.is_none() {
command.arg("--depth").arg("1").arg("--branch").arg(
args.harn_openapi_branch
.as_deref()
.unwrap_or(DEFAULT_HARN_OPENAPI_BRANCH),
);
}
command
.arg(git)
.arg(&checkout)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
let output = command.output().map_err(|error| {
format!("failed to invoke git while cloning harn-openapi from {git}: {error}")
})?;
if !output.status.success() {
return Err(format!(
"failed to clone harn-openapi from {git}: {}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
if let Some(rev) = args.harn_openapi_rev.as_deref() {
let output = process::Command::new("git")
.arg("checkout")
.arg(rev)
.current_dir(&checkout)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.output()
.map_err(|error| format!("failed to invoke git checkout {rev}: {error}"))?;
if !output.status.success() {
return Err(format!(
"failed to checkout harn-openapi rev {rev}: {}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
}
let lib_import_path = resolve_local_harn_openapi(&checkout)?;
Ok(ResolvedHarnOpenapi {
lib_import_path,
_checkout: Some(tmp),
})
}
async fn generate_sdk_source(
harn_openapi_import: &str,
spec_path: &Path,
out_path: &Path,
module_name: &str,
client_name: &str,
default_base_url: &str,
) -> Result<(), PackageError> {
let tmp = tempfile::tempdir()
.map_err(|error| format!("failed to create OpenAPI generation temp dir: {error}"))?;
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
}
let script_path = tmp.path().join("generate_openapi_sdk.harn");
let script = format!(
r#"import {{ codegen_module, parse }} from {import_path}
fn _load_doc(path: string) {{
let raw = read_file(path)
let decoded = try {{
json_parse(raw)
}} catch (e) {{
yaml_parse(raw)
}}
return parse(json_stringify(decoded))
}}
pipeline default() {{
let doc = _load_doc(argv[0])
var options = {{
module_name: argv[2],
client_name: argv[3],
}}
if argv[4] != "" {{
options = {{...options, default_base_url: argv[4]}}
}}
write_file(argv[1], codegen_module(doc, options))
}}
"#,
import_path = harn_string_literal(harn_openapi_import)
);
fs::write(&script_path, script)
.map_err(|error| format!("failed to write {}: {error}", script_path.display()))?;
let outcome = execute_run(
&script_path.display().to_string(),
false,
HashSet::new(),
vec![
spec_path.display().to_string(),
out_path.display().to_string(),
module_name.to_string(),
client_name.to_string(),
default_base_url.to_string(),
],
Vec::new(),
CliLlmMockMode::Off,
None,
RunProfileOptions::default(),
)
.await;
if outcome.exit_code != 0 {
return Err(format!(
"harn-openapi generation failed with exit code {}\n{}{}",
outcome.exit_code, outcome.stderr, outcome.stdout
)
.into());
}
Ok(())
}
fn openapi_template_files(
args: &PackageScaffoldOpenapiArgs,
module_name: &str,
client_name: &str,
spec: &LoadedSpec,
default_base_url: &str,
smoke: Option<&SmokeOperation>,
) -> Result<Vec<(&'static str, String)>, PackageError> {
let description = args.description.clone().unwrap_or_else(|| {
format!(
"Focused Harn SDK package generated from {}.",
spec_title(&spec.value).unwrap_or("an OpenAPI spec")
)
});
let package_name = toml_string_literal(&args.name)?;
let description_toml = toml_string_literal(&description)?;
let repository = toml_string_literal(&format!("https://github.com/OWNER/{}", args.name))?;
let provenance = toml_string_literal(&format!(
"https://github.com/OWNER/{}/releases/tag/v0.1.0",
args.name
))?;
let harn_range = current_harn_range_example();
let dependency = harn_openapi_dependency(args)?;
let smoke_test = smoke_test_source(smoke, default_base_url);
let regen = regen_script_source(
module_name,
client_name,
&spec.relative_path,
default_base_url,
);
let readme = readme_source(
&args.name,
&description,
&spec.source_label,
module_name,
client_name,
);
Ok(vec![
(
"harn.toml",
format!(
r#"[package]
name = {package_name}
version = "0.1.0"
description = {description_toml}
license = "MIT OR Apache-2.0"
repository = {repository}
provenance = {provenance}
harn = "{harn_range}"
docs_url = "docs/api.md"
[exports]
{module_name} = "src/lib.harn"
[dependencies]
harn-openapi = {dependency}
"#
),
),
("scripts/regen.harn", regen),
("tests/smoke.harn", smoke_test),
("README.md", readme),
("LICENSE", "MIT OR Apache-2.0\n".to_string()),
(
".github/workflows/harn-package.yml",
r#"name: Harn OpenAPI SDK package
on:
pull_request:
push:
branches: [main]
jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@cargo-binstall
- run: cargo binstall harn-cli --no-confirm
- run: harn install --locked --offline || harn install
- run: harn check src/lib.harn
- run: harn test tests/
- run: harn package check
- run: harn package docs --check
- run: harn package pack --dry-run
"#
.to_string(),
),
])
}
fn harn_openapi_dependency(args: &PackageScaffoldOpenapiArgs) -> Result<String, PackageError> {
let git = toml_string_literal(
args.harn_openapi_git
.as_deref()
.unwrap_or(DEFAULT_HARN_OPENAPI_GIT),
)?;
let pin = if let Some(rev) = args.harn_openapi_rev.as_deref() {
format!("rev = {}", toml_string_literal(rev)?)
} else {
format!(
"branch = {}",
toml_string_literal(
args.harn_openapi_branch
.as_deref()
.unwrap_or(DEFAULT_HARN_OPENAPI_BRANCH)
)?
)
};
Ok(format!("{{ git = {git}, {pin} }}"))
}
fn regen_script_source(
module_name: &str,
client_name: &str,
spec_relative_path: &str,
default_base_url: &str,
) -> String {
format!(
r#"import {{ codegen_module, parse }} from "harn-openapi/default"
fn _load_doc(path: string) {{
let raw = read_file(path)
let decoded = try {{
json_parse(raw)
}} catch (e) {{
yaml_parse(raw)
}}
return parse(json_stringify(decoded))
}}
pipeline default() {{
let root = source_dir() + "/.."
let spec_path = if len(argv) > 0 {{ argv[0] }} else {{ root + "/" + {spec_path} }}
let out_path = if len(argv) > 1 {{ argv[1] }} else {{ root + "/src/lib.harn" }}
let doc = _load_doc(spec_path)
write_file(
out_path,
codegen_module(
doc,
{{
module_name: {module_name},
client_name: {client_name},
default_base_url: {default_base_url},
}},
),
)
println("wrote " + out_path)
}}
"#,
spec_path = harn_string_literal(spec_relative_path),
module_name = harn_string_literal(module_name),
client_name = harn_string_literal(client_name),
default_base_url = harn_string_literal(default_base_url),
)
}
fn smoke_test_source(smoke: Option<&SmokeOperation>, default_base_url: &str) -> String {
if let Some(smoke) = smoke {
return format!(
r#"import {{ new_client, {fn_name} }} from "../src/lib"
pipeline test_generated_sdk_smoke(task) {{
http_mock_clear()
http_mock({method}, {url}, {{
status: 200,
body: "{{}}",
headers: {{"content-type": "application/json"}},
}})
let client = new_client()
{fn_name}(client)
let calls = http_mock_calls()
assert_eq(len(calls), 1)
assert_eq(calls[0].method, {method})
}}
"#,
fn_name = smoke.function_name,
method = harn_string_literal(&smoke.method),
url = harn_string_literal(&format!(
"{}{}",
default_base_url.trim_end_matches('/'),
smoke.path
)),
);
}
r#"import { new_client, pagination_plans } from "../src/lib"
pipeline test_generated_sdk_smoke(task) {
let client = new_client()
assert_eq(type_of(client), "dict")
assert_eq(type_of(pagination_plans()), "list")
}
"#
.to_string()
}
fn readme_source(
package_name: &str,
description: &str,
spec_source: &str,
module_name: &str,
client_name: &str,
) -> String {
format!(
r#"# {package_name}
{description}
This is a focused Harn SDK package generated from `{spec_source}`. It should
cover the API surface described by that spec; it is not intended to become a
whole-provider clone with hand-written coverage for unrelated products.
## Develop
```bash
harn install
harn check src/lib.harn
harn test tests/
harn package check
harn package docs --check
harn package pack --dry-run
```
## Regenerate
```bash
harn install
harn run scripts/regen.harn
harn package docs
```
`harn-openapi` is declared in `harn.toml` so regeneration works in CI. Pin it
to a reviewed rev before publishing a long-lived SDK package:
```bash
harn add github.com/burin-labs/harn-openapi@<rev>
```
## Install Into Another Project
```bash
harn add ../{package_name}
harn install
```
Consumers import the generated module through the package export:
```harn
import {{ new_client }} from "{package_name}/{module_name}"
```
`new_client()` returns a `{client_name}` handle with the default base URL from
the OpenAPI document. Pass an explicit base URL when testing or targeting a
different environment.
## Hand-Written HTTP
Hand-written `harness.net` calls are acceptable for one-off private endpoints,
temporary admin probes, or API gaps that are not present in the OpenAPI
document. Repeated provider API coverage, connector helpers, and user-facing
package exports should live in this generated SDK or a small Harn wrapper over
it so auth, rate-limit metadata, pagination helpers, and tests stay in one
place.
"#
)
}
fn first_server_url(value: &Value) -> Option<String> {
value
.get("servers")?
.as_array()?
.iter()
.find_map(|server| server.get("url")?.as_str().map(ToString::to_string))
.filter(|url| !url.trim().is_empty())
}
fn spec_title(value: &Value) -> Option<&str> {
value.get("info")?.get("title")?.as_str()
}
#[derive(Debug, Clone)]
struct SmokeOperation {
function_name: String,
method: String,
path: String,
}
fn find_smoke_operation(value: &Value) -> Option<SmokeOperation> {
let paths = value.get("paths")?.as_object()?;
let function_name_counts = operation_function_name_counts(paths);
let mut path_entries: Vec<_> = paths.iter().collect();
path_entries.sort_by(|left, right| left.0.cmp(right.0));
let methods = ["get", "delete", "head", "options", "post", "put", "patch"];
for (path, item) in path_entries {
if path.contains('{') {
continue;
}
let Some(item) = item.as_object() else {
continue;
};
for method in methods {
let Some(operation) = item.get(method).and_then(Value::as_object) else {
continue;
};
if operation.contains_key("requestBody")
|| has_required_parameters(item)
|| has_required_parameters(operation)
{
continue;
}
let operation_id = operation
.get("operationId")
.and_then(Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| synth_operation_id(method, path));
let function_name = sanitize_openapi_function_name(&operation_id);
if function_name_counts
.get(&function_name)
.copied()
.unwrap_or(0)
!= 1
{
continue;
}
return Some(SmokeOperation {
function_name,
method: method.to_ascii_uppercase(),
path: path.to_string(),
});
}
}
None
}
fn operation_function_name_counts(
paths: &serde_json::Map<String, Value>,
) -> std::collections::HashMap<String, usize> {
let mut counts = std::collections::HashMap::new();
let methods = ["get", "delete", "head", "options", "post", "put", "patch"];
for (path, item) in paths {
let Some(item) = item.as_object() else {
continue;
};
for method in methods {
let Some(operation) = item.get(method).and_then(Value::as_object) else {
continue;
};
let operation_id = operation
.get("operationId")
.and_then(Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| synth_operation_id(method, path));
*counts
.entry(sanitize_openapi_function_name(&operation_id))
.or_insert(0) += 1;
}
}
counts
}
fn has_required_parameters(operation: &serde_json::Map<String, Value>) -> bool {
operation
.get("parameters")
.and_then(Value::as_array)
.is_some_and(|parameters| {
parameters.iter().any(|parameter| {
parameter
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
})
})
}
fn synth_operation_id(method: &str, path: &str) -> String {
let cleaned = non_alphanumeric_to_underscore(path);
let trimmed = cleaned.trim_matches('_');
format!("{method}_{trimmed}")
}
fn sanitize_openapi_function_name(raw: &str) -> String {
let mut out = non_alphanumeric_to_underscore(raw)
.trim_matches('_')
.to_ascii_lowercase();
while out.contains("__") {
out = out.replace("__", "_");
}
if out.is_empty() {
out = "op".to_string();
}
if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
out = format!("op_{out}");
}
if is_harn_reserved(&out) {
out.push('_');
}
out
}
fn non_alphanumeric_to_underscore(raw: &str) -> String {
raw.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
.collect()
}
fn is_harn_reserved(value: &str) -> bool {
matches!(
value,
"if" | "else"
| "elif"
| "for"
| "in"
| "while"
| "match"
| "let"
| "var"
| "fn"
| "pub"
| "return"
| "throw"
| "try"
| "catch"
| "finally"
| "pipeline"
| "import"
| "from"
| "as"
| "type"
| "interface"
| "enum"
| "true"
| "false"
| "nil"
| "and"
| "or"
| "not"
| "skill"
| "tool"
| "body"
| "client"
)
}
fn expand_tilde(raw: &str) -> PathBuf {
if raw == "~" {
return std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(raw));
}
if let Some(rest) = raw.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(raw)
}
fn expand_tilde_path(path: &Path) -> PathBuf {
path.to_str()
.map(expand_tilde)
.unwrap_or_else(|| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::package::test_support::{run_git, test_git_command};
use crate::package::{
check_package_impl, generate_package_docs_impl, install_packages_in, pack_package_impl,
PackageWorkspace,
};
#[test]
fn sanitize_openapi_function_names_like_harn_openapi() {
assert_eq!(sanitize_openapi_function_name("get-widget"), "get_widget");
assert_eq!(sanitize_openapi_function_name("123 ping"), "op_123_ping");
assert_eq!(sanitize_openapi_function_name("type"), "type_");
}
#[tokio::test]
async fn load_spec_accepts_file_url() {
let tmp = tempfile::tempdir().unwrap();
let spec = tmp.path().join("openapi.yaml");
fs::write(
&spec,
r#"openapi: 3.1.0
info:
title: Tiny
version: 1.0.0
paths: {}
"#,
)
.unwrap();
let url = Url::from_file_path(&spec).unwrap();
let loaded = load_spec(url.as_str()).await.unwrap();
assert_eq!(loaded.relative_path, "openapi/openapi.yaml");
assert_eq!(
loaded.value.pointer("/info/title").and_then(Value::as_str),
Some("Tiny")
);
}
#[tokio::test]
async fn scaffold_openapi_package_passes_local_package_gates() {
let tmp = tempfile::tempdir().unwrap();
let harn_openapi = fake_harn_openapi_repo(tmp.path());
let rev = run_git(&harn_openapi, &["rev-parse", "HEAD"]);
let spec = tmp.path().join("openapi.json");
fs::write(
&spec,
r#"{
"openapi": "3.1.0",
"info": {"title": "Tiny", "version": "1.0.0"},
"servers": [{"url": "https://api.example.test"}],
"paths": {
"/ping": {
"get": {
"operationId": "ping",
"responses": {"200": {"description": "ok"}}
}
}
}
}
"#,
)
.unwrap();
let out = tmp.path().join("tiny-sdk-harn");
scaffold_openapi_package(&PackageScaffoldOpenapiArgs {
name: "tiny-sdk-harn".to_string(),
module_name: Some("tiny_sdk".to_string()),
client_name: Some("TinyClient".to_string()),
spec: spec.display().to_string(),
out: Some(out.clone()),
description: None,
default_base_url: None,
harn_openapi_path: Some(harn_openapi.clone()),
harn_openapi_git: Some(harn_openapi.display().to_string()),
harn_openapi_rev: Some(rev),
harn_openapi_branch: None,
force: false,
})
.await
.unwrap();
assert!(out.join("src/lib.harn").exists());
assert!(out.join("scripts/regen.harn").exists());
assert!(out.join("docs/api.md").exists());
let manifest = fs::read_to_string(out.join("harn.toml")).unwrap();
assert!(manifest.contains("[exports]\ntiny_sdk = \"src/lib.harn\""));
assert!(manifest.contains("harn-openapi = { git = "));
install_packages_in(
&PackageWorkspace::for_test(&out, tmp.path().join(".cache")),
false,
None,
false,
)
.unwrap();
let outcome = execute_run(
&out.join("tests/smoke.harn").display().to_string(),
false,
HashSet::new(),
Vec::new(),
Vec::new(),
CliLlmMockMode::Off,
None,
RunProfileOptions::default(),
)
.await;
assert_eq!(outcome.exit_code, 0, "{}{}", outcome.stderr, outcome.stdout);
let report = check_package_impl(Some(&out)).unwrap();
assert!(report.errors.is_empty(), "{:?}", report.errors);
generate_package_docs_impl(Some(&out), None, true).unwrap();
pack_package_impl(Some(&out), None, true).unwrap();
}
#[tokio::test]
async fn force_preserves_extra_files() {
let tmp = tempfile::tempdir().unwrap();
let harn_openapi = fake_harn_openapi_repo(tmp.path());
let rev = run_git(&harn_openapi, &["rev-parse", "HEAD"]);
let spec = tmp.path().join("openapi.json");
fs::write(
&spec,
r#"{"openapi":"3.1.0","info":{"title":"Tiny","version":"1.0.0"},"paths":{}}"#,
)
.unwrap();
let out = tmp.path().join("tiny-sdk-harn");
fs::create_dir_all(&out).unwrap();
fs::write(out.join("keep.txt"), "keep").unwrap();
scaffold_openapi_package(&PackageScaffoldOpenapiArgs {
name: "tiny-sdk-harn".to_string(),
module_name: Some("tiny_sdk".to_string()),
client_name: Some("TinyClient".to_string()),
spec: spec.display().to_string(),
out: Some(out.clone()),
description: None,
default_base_url: Some("https://api.example.test".to_string()),
harn_openapi_path: Some(harn_openapi.clone()),
harn_openapi_git: Some(harn_openapi.display().to_string()),
harn_openapi_rev: Some(rev),
harn_openapi_branch: None,
force: true,
})
.await
.unwrap();
assert_eq!(fs::read_to_string(out.join("keep.txt")).unwrap(), "keep");
assert!(out.join("harn.toml").exists());
}
fn fake_harn_openapi_repo(root: &Path) -> PathBuf {
let repo = root.join(format!("fake-harn-openapi-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(repo.join("src")).unwrap();
fs::write(
repo.join("harn.toml"),
r#"[package]
name = "harn-openapi"
version = "0.1.0"
description = "Fake harn-openapi for scaffold tests."
license = "MIT OR Apache-2.0"
repository = "https://example.test/harn-openapi"
harn = ">=0.8,<0.9"
docs_url = "docs/api.md"
[exports]
default = "src/lib.harn"
[dependencies]
"#,
)
.unwrap();
fs::create_dir_all(repo.join("docs")).unwrap();
fs::write(repo.join("docs/api.md"), "# API\n").unwrap();
fs::write(
repo.join("src/lib.harn"),
r#"pub fn parse(raw: string) {
return json_parse(raw)
}
pub fn codegen_module(doc, options) -> string {
return "/* generated */\n"
+ "/** Create a new client handle. */\n"
+ "pub fn new_client(base_url: string = \"https://api.example.test\", extra_headers: dict = {}) -> dict {\n"
+ " return {base_url: base_url, extra_headers: extra_headers}\n"
+ "}\n\n"
+ "/** Pagination plans detected from the OpenAPI operation shapes. */\n"
+ "pub fn pagination_plans() -> list {\n"
+ " return []\n"
+ "}\n\n"
+ "/** ping */\n"
+ "pub fn ping(client: dict) -> nil {\n"
+ " let resp = http_get(client.base_url + \"/ping\", {headers: {}})\n"
+ " if resp.status >= 400 { throw \"ping failed\" }\n"
+ " return nil\n"
+ "}\n"
}
"#,
)
.unwrap();
let init = test_git_command(&repo)
.args(["init", "-b", "main"])
.output()
.unwrap();
if !init.status.success() {
let fallback = test_git_command(&repo).arg("init").output().unwrap();
assert!(
fallback.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&fallback.stderr)
);
}
run_git(&repo, &["config", "user.email", "tests@example.com"]);
run_git(&repo, &["config", "user.name", "Harn Tests"]);
run_git(&repo, &["config", "core.hooksPath", "/dev/null"]);
run_git(&repo, &["add", "."]);
run_git(&repo, &["commit", "-m", "initial"]);
repo
}
}