Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/ov_cli/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use serde::de::DeserializeOwned;
use serde_json::Value;
use serde_json::{Map, Value};
use std::env;
use std::path::Path;

Expand Down Expand Up @@ -482,10 +482,12 @@ impl HttpClient {
exclude: Option<String>,
directly_upload_media: bool,
watch_interval: f64,
resource_args: Option<Map<String, Value>>,
show_progress: bool,
verbose: bool,
) -> Result<serde_json::Value> {
let path_obj = Path::new(path);
let args = Value::Object(resource_args.unwrap_or_default());

// Determine effective parent and create_parent flag.
// Only send create_parent when the user explicitly selected
Expand Down Expand Up @@ -541,6 +543,7 @@ impl HttpClient {
"exclude": exclude,
"directly_upload_media": directly_upload_media,
"watch_interval": watch_interval,
"args": args.clone(),
}));

let dynamic_timeout =
Expand Down Expand Up @@ -575,6 +578,7 @@ impl HttpClient {
"exclude": exclude,
"directly_upload_media": directly_upload_media,
"watch_interval": watch_interval,
"args": args.clone(),
}));

let dynamic_timeout =
Expand All @@ -597,6 +601,7 @@ impl HttpClient {
"exclude": exclude,
"directly_upload_media": directly_upload_media,
"watch_interval": watch_interval,
"args": args.clone(),
}));

self.post("/api/v1/resources", &body).await
Expand All @@ -616,6 +621,7 @@ impl HttpClient {
"exclude": exclude,
"directly_upload_media": directly_upload_media,
"watch_interval": watch_interval,
"args": args,
}));

self.post("/api/v1/resources", &body).await
Expand Down
3 changes: 3 additions & 0 deletions crates/ov_cli/src/commands/resources.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::client::HttpClient;
use crate::error::Result;
use crate::output::{OutputFormat, output_success};
use serde_json::{Map, Value};

pub async fn add_resource(
client: &HttpClient,
Expand All @@ -18,6 +19,7 @@ pub async fn add_resource(
exclude: Option<String>,
directly_upload_media: bool,
watch_interval: f64,
resource_args: Option<Map<String, Value>>,
format: OutputFormat,
compact: bool,
show_progress: bool,
Expand All @@ -39,6 +41,7 @@ pub async fn add_resource(
exclude,
directly_upload_media,
watch_interval,
resource_args,
show_progress,
verbose,
)
Expand Down
159 changes: 159 additions & 0 deletions crates/ov_cli/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::error::{Error, Result};
use crate::theme;
use crate::tui;
use colored::Colorize;
use serde_json::{Map, Value};

pub async fn handle_add_resource(
mut path: String,
Expand All @@ -24,6 +25,7 @@ pub async fn handle_add_resource(
exclude: Option<String>,
no_directly_upload_media: bool,
watch_interval: f64,
resource_args: Option<String>,
ctx: CliContext,
) -> Result<()> {
let is_url =
Expand Down Expand Up @@ -68,6 +70,7 @@ pub async fn handle_add_resource(
merge_csv_options(ctx.config.upload.ignore_dirs.clone(), ignore_dirs);
let effective_include = merge_csv_options(ctx.config.upload.include.clone(), include);
let effective_exclude = merge_csv_options(ctx.config.upload.exclude.clone(), exclude);
let add_resource_args = parse_add_resource_args(resource_args.as_deref())?;

let effective_timeout = if wait {
timeout.unwrap_or(60.0).max(ctx.config.timeout)
Expand Down Expand Up @@ -100,6 +103,7 @@ pub async fn handle_add_resource(
effective_exclude,
directly_upload_media,
watch_interval,
add_resource_args,
ctx.output_format,
ctx.compact,
ctx.should_show_progress(),
Expand All @@ -108,6 +112,123 @@ pub async fn handle_add_resource(
.await
}

fn parse_add_resource_args(raw: Option<&str>) -> Result<Option<Map<String, Value>>> {
let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else {
return Ok(None);
};

if raw.starts_with('{') {
let value: Value = serde_json::from_str(raw)
.map_err(|e| Error::Client(format!("Invalid --args JSON object: {e}")))?;
return match value {
Value::Object(map) => Ok(Some(map)),
_ => Err(Error::Client(
"--args JSON form must be an object, e.g. '{\"feishu_access_token\":\"u-...\"}'"
.to_string(),
)),
};
}

let mut args = Map::new();
for item in split_add_resource_args(raw)? {
let Some((key, value)) = item.split_once(':') else {
return Err(Error::Client(format!(
"Invalid --args item '{item}'. Expected key:value."
)));
};
let key = key.trim();
if key.is_empty() {
return Err(Error::Client(
"Invalid --args item with empty key.".to_string(),
));
}
args.insert(key.to_string(), parse_add_resource_arg_value(value.trim()));
}
Ok(Some(args))
}

fn split_add_resource_args(raw: &str) -> Result<Vec<String>> {
let mut items = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escape = false;
let mut depth = 0_i32;

for ch in raw.chars() {
if escape {
current.push(ch);
escape = false;
continue;
}
if ch == '\\' {
current.push(ch);
escape = true;
continue;
}
if let Some(q) = quote {
current.push(ch);
if ch == q {
quote = None;
}
continue;
}
match ch {
'"' | '\'' => {
quote = Some(ch);
current.push(ch);
}
'{' | '[' => {
depth += 1;
current.push(ch);
}
'}' | ']' => {
depth -= 1;
if depth < 0 {
return Err(Error::Client("Invalid --args nesting.".to_string()));
}
current.push(ch);
}
',' if depth == 0 => {
let item = current.trim();
if !item.is_empty() {
items.push(item.to_string());
}
current.clear();
}
_ => current.push(ch),
}
}

if quote.is_some() || depth != 0 {
return Err(Error::Client(
"Invalid --args quoting or nesting.".to_string(),
));
}
let item = current.trim();
if !item.is_empty() {
items.push(item.to_string());
}
Ok(items)
}

fn parse_add_resource_arg_value(raw: &str) -> Value {
if raw.is_empty() {
return Value::String(String::new());
}
if let Ok(value) = serde_json::from_str::<Value>(raw) {
return value;
}
let unquoted = raw
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
raw.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(raw);
Value::String(unquoted.to_string())
}

pub async fn handle_add_skill(
data: String,
wait: bool,
Expand Down Expand Up @@ -1432,6 +1553,44 @@ pub async fn handle_tui(uri: String, ctx: CliContext) -> Result<()> {
tui::run_tui(client, &uri).await
}

#[cfg(test)]
mod add_resource_args_tests {
use super::*;

#[test]
fn parses_key_value_args() {
let args = parse_add_resource_args(Some("feishu_access_token:u-test,limit:3,deep:true"))
.expect("args should parse")
.expect("args should be present");

assert_eq!(
args.get("feishu_access_token"),
Some(&Value::String("u-test".to_string()))
);
assert_eq!(args.get("limit"), Some(&serde_json::json!(3)));
assert_eq!(args.get("deep"), Some(&Value::Bool(true)));
}

#[test]
fn parses_json_object_args() {
let args = parse_add_resource_args(Some(r#"{"feishu_access_token":"u-test"}"#))
.expect("json object should parse")
.expect("args should be present");

assert_eq!(
args.get("feishu_access_token"),
Some(&Value::String("u-test".to_string()))
);
}

#[test]
fn rejects_invalid_args_item() {
let err = parse_add_resource_args(Some("feishu_access_token")).unwrap_err();

assert!(err.to_string().contains("Expected key:value"));
}
}

#[cfg(test)]
mod config_switch_prompt_tests {
use super::*;
Expand Down
25 changes: 25 additions & 0 deletions crates/ov_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ enum Commands {
/// Watch interval in minutes for automatic resource monitoring (0 = no monitoring)
#[arg(long, default_value = "0")]
watch_interval: f64,
/// Parser-specific import options, e.g. --args feishu_access_token:u-xxx
#[arg(long = "args")]
resource_args: Option<String>,
#[command(flatten)]
upload_options: UploadCliOptions,
},
Expand Down Expand Up @@ -2164,6 +2167,7 @@ async fn main() {
exclude,
no_directly_upload_media,
watch_interval,
resource_args,
upload_options,
} => {
let ctx =
Expand All @@ -2183,6 +2187,7 @@ async fn main() {
exclude,
no_directly_upload_media,
watch_interval,
resource_args,
ctx,
)
.await
Expand Down Expand Up @@ -2981,6 +2986,7 @@ mod tests {
assert!(help.contains("--progress"));
assert!(help.contains("--no-progress"));
assert!(help.contains("--verbose"));
assert!(help.contains("--args"));
}

#[test]
Expand Down Expand Up @@ -3041,6 +3047,25 @@ mod tests {
assert!(Cli::try_parse_from(["ov", "skills", "update", "--progress"]).is_err());
}

#[test]
fn cli_parses_add_resource_args() {
let cli = Cli::try_parse_from([
"ov",
"add-resource",
"https://example.feishu.cn/docx/doc123",
"--args",
"feishu_access_token:u-test",
])
.expect("add-resource args should parse");

match cli.command {
Commands::AddResource { resource_args, .. } => {
assert_eq!(resource_args.as_deref(), Some("feishu_access_token:u-test"));
}
_ => panic!("expected add-resource command"),
}
}

#[test]
fn cli_parses_skills_command_group() {
let list = Cli::try_parse_from(["ov", "skills", "list", "--limit", "25"])
Expand Down
Loading
Loading