use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde_json::Value;
use crate::{budget_arg, generate_with, slash, Platform, Resolved};
#[derive(Debug, Clone, Copy)]
pub struct InstallEnv<'a> {
pub cwd: &'a Path,
pub home: Option<&'a Path>,
pub dsh_home: Option<&'a Path>,
pub openclaw_state_dir: Option<&'a Path>,
pub hermes_home: Option<&'a Path>,
pub kimi_code_home: Option<&'a Path>,
pub codex_home: Option<&'a Path>,
pub path_env: Option<&'a OsStr>,
}
#[derive(Debug, Clone, Copy)]
pub struct InstallOptions<'a> {
pub platform: Platform,
pub resolved: &'a Resolved,
pub env: &'a InstallEnv<'a>,
pub dry_run: bool,
pub yes: bool,
pub host_bin: Option<&'a Path>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallState {
Fresh,
Updated,
AlreadyCurrent,
HostExecuted,
HostPrinted,
DryRun,
}
impl InstallState {
pub fn label(self) -> &'static str {
match self {
InstallState::Fresh => "已写入(全新创建)",
InstallState::Updated => "已更新(原文件已备份)",
InstallState::AlreadyCurrent => "已是最新,逐字节未动",
InstallState::HostExecuted => "已执行宿主 CLI",
InstallState::HostPrinted => "已打印宿主命令行(未执行;加 --yes 执行)",
InstallState::DryRun => "dry-run(不落盘)",
}
}
}
#[derive(Debug)]
pub struct InstallReport {
pub state: InstallState,
pub target: Option<PathBuf>,
pub backup: Option<PathBuf>,
pub diff: Vec<String>,
pub actions: Vec<String>,
pub printed: Option<String>,
}
#[derive(Debug)]
pub struct InstalledEntry {
pub path: PathBuf,
pub command: String,
pub args: Vec<String>,
}
#[derive(Debug)]
pub enum InstallError {
Unsupported(String),
TargetUnresolved(String),
BadExisting(String),
Io(String),
HostNotFound(String),
HostFailed(String),
Generate(String),
}
impl InstallError {
pub fn message(&self) -> String {
match self {
InstallError::Unsupported(message)
| InstallError::TargetUnresolved(message)
| InstallError::BadExisting(message)
| InstallError::Io(message)
| InstallError::HostNotFound(message)
| InstallError::HostFailed(message)
| InstallError::Generate(message) => message.clone(),
}
}
}
pub fn install(options: &InstallOptions) -> Result<InstallReport, InstallError> {
match options.platform {
Platform::Codex => Err(InstallError::Unsupported(
"codex 主配置是 TOML 文本面(config.toml),文本合并的风险大于收益,本版不支持 \
--install 直写;用 `wanning init --platform codex --out <path>` 生成片段后按 \
docs/plugins/codex.md 人工追加"
.to_string(),
)),
Platform::DeepSeekHarness => install_dsh(options),
Platform::OpenClaw | Platform::Hermes => install_host(options),
Platform::ClaudeCode | Platform::Kimi | Platform::Trae | Platform::WorkBuddy => {
install_mcp_json(options)
}
}
}
fn mcp_json_path(platform: Platform, env: &InstallEnv) -> PathBuf {
let relative: &[&str] = match platform {
Platform::ClaudeCode => &[".mcp.json"],
Platform::Kimi => &[".kimi-code", "mcp.json"],
Platform::Trae => &[".trae", "mcp.json"],
Platform::WorkBuddy => &[".workbuddy", "mcp.json"],
_ => unreachable!("mcp.json 平台才进这里"),
};
let mut path = env.cwd.to_path_buf();
for part in relative {
path = path.join(part);
}
path
}
fn generated_entry(options: &InstallOptions) -> Result<Value, InstallError> {
let artifact = artifact_for(options)?;
let document: Value = serde_json::from_str(&artifact.content)
.map_err(|error| InstallError::Generate(format!("生成产物不是合法 JSON: {error}")))?;
Ok(document["mcpServers"]["wanning"].clone())
}
fn parse_mcp_document(text: &str) -> Result<Value, InstallError> {
let document: Value = serde_json::from_str(text).map_err(|error| {
InstallError::BadExisting(format!(
"现有配置不是合法 JSON({error}),拒绝动它;修好或人工处理后再装"
))
})?;
if !document.is_object() {
return Err(InstallError::BadExisting(format!(
"现有配置顶层是{},不是 JSON 对象,拒绝动它",
type_name(&document)
)));
}
match document.get("mcpServers") {
None | Some(Value::Object(_)) => Ok(document),
Some(other) => Err(InstallError::BadExisting(format!(
"现有配置的 mcpServers 是{},不是对象,拒绝动它",
type_name(other)
))),
}
}
struct McpPlan {
unchanged: bool,
diff: Vec<String>,
}
fn plan_mcp_merge(document: Option<&Value>, entry: &Value) -> McpPlan {
let existing = document
.and_then(|doc| doc.get("mcpServers"))
.and_then(|servers| servers.get("wanning"));
match existing {
Some(current) if current == entry => McpPlan {
unchanged: true,
diff: Vec::new(),
},
Some(current) => McpPlan {
unchanged: false,
diff: entry_diff(current, entry),
},
None => McpPlan {
unchanged: false,
diff: Vec::new(),
},
}
}
fn entry_diff(old: &Value, new: &Value) -> Vec<String> {
let mut lines = Vec::new();
match (old.as_object(), new.as_object()) {
(Some(old_map), Some(new_map)) => {
let keys: BTreeSet<&String> = old_map.keys().chain(new_map.keys()).collect();
for key in keys {
let old_value = old_map.get(key);
let new_value = new_map.get(key);
if old_value == new_value {
continue;
}
match (old_value, new_value) {
(Some(value), None) => lines.push(format!("- {key}: {}", render(value))),
(None, Some(value)) => lines.push(format!("+ {key}: {}", render(value))),
(Some(old_value), Some(new_value)) => {
lines.push(format!("- {key}: {}", render(old_value)));
lines.push(format!("+ {key}: {}", render(new_value)));
}
(None, None) => unreachable!("键来自两 map 的并集"),
}
}
}
_ => {
lines.push(format!("- {}", render(old)));
lines.push(format!("+ {}", render(new)));
}
}
lines
}
fn render(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "<不可序列化>".to_string())
}
fn install_mcp_json(options: &InstallOptions) -> Result<InstallReport, InstallError> {
let path = mcp_json_path(options.platform, options.env);
let entry = generated_entry(options)?;
let existed = path.exists();
let raw = if existed { read_optional(&path)? } else { None };
let document = match &raw {
Some(text) => Some(parse_mcp_document(text)?),
None => None,
};
let plan = plan_mcp_merge(document.as_ref(), &entry);
if options.dry_run {
let mut actions = vec![format!("将写入 {}", path.display())];
if plan.unchanged {
actions.push("已是最新,--dry-run 也不会有任何改动".to_string());
} else {
if existed {
actions.push(format!(
"将先备份原文件到 {}",
backup_path_for(&path).display()
));
}
if let Some(old) = document
.as_ref()
.and_then(|doc| doc.get("mcpServers"))
.and_then(|servers| servers.get("wanning"))
{
for line in entry_diff(old, &entry) {
actions.push(format!(" {line}"));
}
}
}
return Ok(InstallReport {
state: InstallState::DryRun,
target: None,
backup: None,
diff: plan.diff,
actions,
printed: None,
});
}
if plan.unchanged {
return Ok(InstallReport {
state: InstallState::AlreadyCurrent,
target: Some(path.clone()),
backup: None,
diff: Vec::new(),
actions: vec![format!("{} 已是最新,未改动", path.display())],
printed: None,
});
}
let mut merged = document.unwrap_or_else(|| serde_json::json!({}));
merged["mcpServers"]["wanning"] = entry;
let mut content = serde_json::to_string_pretty(&merged)
.map_err(|error| InstallError::Io(format!("序列化配置失败: {error}")))?;
content.push('\n');
let backup = if existed {
let backup_path = backup_path_for(&path);
fs::copy(&path, &backup_path)
.map_err(|error| InstallError::Io(format!("备份 {} 失败: {error}", path.display())))?;
Some(backup_path)
} else {
None
};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|error| {
InstallError::Io(format!("创建 {} 失败: {error}", parent.display()))
})?;
}
}
fs::write(&path, content.as_bytes())
.map_err(|error| InstallError::Io(format!("写入 {} 失败: {error}", path.display())))?;
let mut actions = vec![format!(
"{} → {}",
path.display(),
if existed {
"更新 wanning 条目"
} else {
"全新创建"
}
)];
if let Some(backup) = &backup {
actions.push(format!("原文件已备份到 {}", backup.display()));
}
for line in &plan.diff {
actions.push(format!(" {line}"));
}
Ok(InstallReport {
state: if existed {
InstallState::Updated
} else {
InstallState::Fresh
},
target: Some(path),
backup,
diff: plan.diff,
actions,
printed: None,
})
}
fn dsh_patch_path(env: &InstallEnv) -> Result<PathBuf, InstallError> {
let Some(dsh_home) = env.dsh_home else {
return Err(InstallError::TargetUnresolved(
"deepseek-harness 的落点是 $DSH_HOME/cordis.patch.yml,但 DSH_HOME 未设置;\
不猜落点,设好 DSH_HOME 后重试"
.to_string(),
));
};
Ok(dsh_home.join("cordis.patch.yml"))
}
fn dsh_block_lines(options: &InstallOptions) -> Result<Vec<String>, InstallError> {
let artifact = artifact_for(options)?;
let block: Vec<String> = artifact
.content
.lines()
.skip_while(|line| !line.starts_with("- "))
.map(str::to_string)
.collect();
if block.is_empty() {
return Err(InstallError::Generate(
"生成产物里没有 `- insert:` 块".to_string(),
));
}
Ok(block)
}
fn scan_patch_blocks(text: &str) -> Result<Vec<(usize, usize)>, InstallError> {
let lines: Vec<&str> = text.lines().collect();
let mut blocks = Vec::new();
let mut index = 0;
while index < lines.len() {
let line = lines[index];
if line.starts_with("- ") {
let start = index;
index += 1;
while index < lines.len() {
let follow = lines[index];
if follow.starts_with("- ") {
break;
}
if follow.trim().is_empty()
|| follow.starts_with('#')
|| follow.starts_with(' ')
|| follow.starts_with('\t')
{
index += 1;
continue;
}
return Err(InstallError::BadExisting(format!(
"cordis.patch.yml 顶层不是 insert 列表(第 {} 行 `{}`),拒绝动它",
index + 1,
follow
)));
}
blocks.push((start, index));
} else if line.trim().is_empty() || line.starts_with('#') {
index += 1;
} else {
return Err(InstallError::BadExisting(format!(
"cordis.patch.yml 顶层不是 insert 列表(第 {} 行 `{}`),拒绝动它",
index + 1,
line
)));
}
}
Ok(blocks)
}
fn install_dsh(options: &InstallOptions) -> Result<InstallReport, InstallError> {
let path = dsh_patch_path(options.env)?;
let block = dsh_block_lines(options)?;
let existing = read_optional(&path)?;
let Some(existing_text) = existing else {
let content = block.join("\n") + "\n";
if options.dry_run {
let mut actions = vec![format!("将写入 {}", path.display())];
for line in &block {
actions.push(format!(" + {line}"));
}
return Ok(dry_run_report(actions));
}
fs::create_dir_all(options.env.dsh_home.expect("上面已判定存在"))
.map_err(|error| InstallError::Io(format!("创建 {} 失败: {error}", path.display())))?;
fs::write(&path, content.as_bytes())
.map_err(|error| InstallError::Io(format!("写入 {} 失败: {error}", path.display())))?;
return Ok(InstallReport {
state: InstallState::Fresh,
target: Some(path.clone()),
backup: None,
diff: Vec::new(),
actions: vec![format!("{} → 全新创建(append 块写入)", path.display())],
printed: None,
});
};
let lines: Vec<&str> = existing_text.lines().collect();
let blocks = scan_patch_blocks(&existing_text)?;
let span = blocks.iter().copied().find(|&(start, end)| {
lines[start..end]
.iter()
.any(|line| line.contains("id: wanning-gate"))
});
let (new_text, diff, state) = match span {
None => {
let mut text = existing_text.clone();
if !text.ends_with('\n') && !text.is_empty() {
text.push('\n');
}
let mut diff = Vec::new();
for line in &block {
diff.push(format!("+ {line}"));
}
(text + &block.join("\n") + "\n", diff, InstallState::Updated)
}
Some((start, end))
if lines[start..end]
.iter()
.copied()
.eq(block.iter().map(String::as_str)) =>
{
(
existing_text.clone(),
Vec::new(),
InstallState::AlreadyCurrent,
)
}
Some((start, end)) => {
let mut diff = Vec::new();
for line in &lines[start..end] {
diff.push(format!("- {line}"));
}
for line in &block {
diff.push(format!("+ {line}"));
}
let mut rebuilt: Vec<&str> = Vec::new();
rebuilt.extend_from_slice(&lines[..start]);
rebuilt.extend(block.iter().map(String::as_str));
rebuilt.extend_from_slice(&lines[end..]);
let mut text = rebuilt.join("\n");
text.push('\n');
(text, diff, InstallState::Updated)
}
};
if state == InstallState::AlreadyCurrent {
return Ok(InstallReport {
state,
target: Some(path.clone()),
backup: None,
diff: Vec::new(),
actions: vec![format!("{} 已是最新,未改动", path.display())],
printed: None,
});
}
if options.dry_run {
let mut actions = vec![format!("将写入 {}", path.display())];
for line in &diff {
actions.push(format!(" {line}"));
}
return Ok(dry_run_report(actions));
}
let backup_path = backup_path_for(&path);
fs::copy(&path, &backup_path)
.map_err(|error| InstallError::Io(format!("备份 {} 失败: {error}", path.display())))?;
fs::write(&path, new_text.as_bytes())
.map_err(|error| InstallError::Io(format!("写入 {} 失败: {error}", path.display())))?;
let mut actions = vec![format!("{} → {}", path.display(), "合并 wanning 块")];
actions.push(format!("原文件已备份到 {}", backup_path.display()));
for line in &diff {
actions.push(format!(" {line}"));
}
Ok(InstallReport {
state: InstallState::Updated,
target: Some(path),
backup: Some(backup_path),
diff,
actions,
printed: None,
})
}
fn dry_run_report(actions: Vec<String>) -> InstallReport {
InstallReport {
state: InstallState::DryRun,
target: None,
backup: None,
diff: Vec::new(),
actions,
printed: None,
}
}
fn host_name(platform: Platform) -> &'static str {
match platform {
Platform::OpenClaw => "openclaw",
Platform::Hermes => "hermes",
_ => unreachable!("宿主 CLI 平台才进这里"),
}
}
fn install_host(options: &InstallOptions) -> Result<InstallReport, InstallError> {
let artifact = artifact_for(options)?;
let printed = artifact.content.clone();
let name = host_name(options.platform);
if options.dry_run {
let mut actions = vec![format!("将执行宿主 CLI:{}", printed.trim_end())];
actions.push("dry-run 不执行,零副作用".to_string());
return Ok(InstallReport {
state: InstallState::DryRun,
target: None,
backup: None,
diff: Vec::new(),
actions,
printed: Some(printed),
});
}
if !options.yes {
return Ok(InstallReport {
state: InstallState::HostPrinted,
target: None,
backup: None,
diff: Vec::new(),
actions: vec![format!(
"复制执行下面的命令即完成挂载(或加 --yes 让 wanning 代执行)"
)],
printed: Some(printed),
});
}
let args = host_args(options, &printed)?;
let program = resolve_host(name, options.host_bin, options.env.path_env)?;
let (code, stdout, stderr) = exec_host(&program, &args, options.platform == Platform::Hermes)?;
if code != 0 {
return Err(InstallError::HostFailed(format!(
"宿主 CLI {} 退出码 {code},安装失败(fail-closed,不回滚宿主配置);\
stdout: {} stderr: {}",
program.display(),
stdout.trim(),
stderr.trim()
)));
}
Ok(InstallReport {
state: InstallState::HostExecuted,
target: None,
backup: None,
diff: Vec::new(),
actions: vec![format!("已执行:{} {}", program.display(), args.join(" "))],
printed: Some(printed),
})
}
fn host_args(options: &InstallOptions, printed: &str) -> Result<Vec<String>, InstallError> {
match options.platform {
Platform::OpenClaw => {
let line = printed.trim_end();
let payload = line
.strip_prefix("openclaw mcp set wanning '")
.and_then(|rest| rest.strip_suffix('\''))
.ok_or_else(|| {
InstallError::Generate(
"openclaw 命令行形态不符合预期(单引号包裹 payload)".to_string(),
)
})?;
Ok(vec![
"mcp".to_string(),
"set".to_string(),
"wanning".to_string(),
payload.to_string(),
])
}
Platform::Hermes => Ok(vec![
"mcp".to_string(),
"add".to_string(),
"wanning".to_string(),
"--command".to_string(),
slash(&options.resolved.mcp_bin),
"--args".to_string(),
"--wal".to_string(),
slash(&options.resolved.wal),
"--budget".to_string(),
budget_arg(),
]),
_ => unreachable!("宿主 CLI 平台才进这里"),
}
}
fn resolve_host(
name: &str,
host_bin: Option<&Path>,
path_env: Option<&OsStr>,
) -> Result<PathBuf, InstallError> {
if let Some(explicit) = host_bin {
if explicit.is_file() {
return Ok(explicit.to_path_buf());
}
return Err(InstallError::HostNotFound(format!(
"宿主 CLI {name} 在指定路径 {} 不存在(--host-bin 必须指向真实可执行文件)",
explicit.display()
)));
}
let Some(path_env) = path_env else {
return Err(InstallError::HostNotFound(format!(
"环境里没有 PATH,解析不到宿主 CLI {name};用 --host-bin 显式指定"
)));
};
for dir in std::env::split_paths(path_env) {
let mut candidates = vec![dir.join(name)];
if cfg!(windows) {
for ext in [".exe", ".cmd", ".bat"] {
candidates.push(dir.join(format!("{name}{ext}")));
}
}
for candidate in candidates {
if candidate.is_file() {
return Ok(candidate);
}
}
}
Err(InstallError::HostNotFound(format!(
"宿主 CLI {name} 不在 PATH 里;先安装它,或用 --host-bin 显式指定"
)))
}
fn exec_host(
program: &Path,
args: &[String],
feed_yes: bool,
) -> Result<(i32, String, String), InstallError> {
let mut command = Command::new(program);
command.args(args);
command.stdout(Stdio::piped()).stderr(Stdio::piped());
command.stdin(if feed_yes {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = command
.spawn()
.map_err(|error| InstallError::HostNotFound(format!("宿主 CLI 无法启动({error})")))?;
if feed_yes {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(b"y\n");
}
}
let output = child
.wait_with_output()
.map_err(|error| InstallError::HostFailed(format!("等待宿主 CLI 失败: {error}")))?;
let code = output.status.code().unwrap_or(-1);
Ok((
code,
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
))
}
pub fn read_installed_entry(
platform: Platform,
env: &InstallEnv,
) -> Result<Option<InstalledEntry>, InstallError> {
match platform {
Platform::ClaudeCode | Platform::Kimi | Platform::Trae | Platform::WorkBuddy => {
let path = mcp_json_path(platform, env);
let Some(text) = read_optional(&path)? else {
return Ok(None);
};
let document = parse_mcp_document(&text)?;
match document
.get("mcpServers")
.and_then(|servers| servers.get("wanning"))
{
Some(value) => entry_from_value(path, value),
None => Ok(None),
}
}
Platform::Codex => {
let Some(home) = env.codex_home else {
return Ok(None);
};
let path = home.join("config.toml");
let Some(text) = read_optional(&path)? else {
return Ok(None);
};
read_codex_fragment(path, &text)
}
Platform::OpenClaw => {
let Some(dir) = env.openclaw_state_dir else {
return Ok(None);
};
let path = dir.join("openclaw.json");
let Some(text) = read_optional(&path)? else {
return Ok(None);
};
let document: Value = serde_json::from_str(&text).map_err(|error| {
InstallError::BadExisting(format!(
"{} 不是合法 JSON({error}),拒绝解读",
path.display()
))
})?;
match document.pointer("/mcp/servers/wanning") {
Some(value) => entry_from_value(path, value),
None => Ok(None),
}
}
Platform::Hermes => {
let Some(dir) = env.hermes_home else {
return Ok(None);
};
let path = dir.join("config.yaml");
let Some(text) = read_optional(&path)? else {
return Ok(None);
};
read_hermes_config(path, &text)
}
Platform::DeepSeekHarness => {
let path = dsh_patch_path(env)?;
let Some(text) = read_optional(&path)? else {
return Ok(None);
};
read_dsh_block(path, &text)
}
}
}
fn entry_from_value(path: PathBuf, value: &Value) -> Result<Option<InstalledEntry>, InstallError> {
let Some(object) = value.as_object() else {
return Err(InstallError::BadExisting(format!(
"{} 里的 wanning 条目不是对象,拒绝解读",
path.display()
)));
};
let Some(command) = object.get("command").and_then(Value::as_str) else {
return Err(InstallError::BadExisting(format!(
"{} 里的 wanning 条目缺 command 字符串,拒绝解读",
path.display()
)));
};
let Some(args) = object.get("args").and_then(Value::as_array) else {
return Err(InstallError::BadExisting(format!(
"{} 里的 wanning 条目缺 args 数组,拒绝解读",
path.display()
)));
};
let mut parsed = Vec::new();
for arg in args {
let Some(text) = arg.as_str() else {
return Err(InstallError::BadExisting(format!(
"{} 里的 wanning 条目 args 含非字符串项,拒绝解读",
path.display()
)));
};
parsed.push(text.to_string());
}
Ok(Some(InstalledEntry {
path,
command: command.to_string(),
args: parsed,
}))
}
fn read_codex_fragment(path: PathBuf, text: &str) -> Result<Option<InstalledEntry>, InstallError> {
let lines: Vec<&str> = text.lines().collect();
for (index, line) in lines.iter().enumerate() {
if line.trim() != "[mcp_servers.wanning]" {
continue;
}
let mut command: Option<String> = None;
let mut args: Vec<String> = Vec::new();
for follow in &lines[index + 1..] {
if follow.trim_start().starts_with('[') {
break;
}
let content = follow.trim();
if content.is_empty() || content.starts_with('#') {
continue;
}
if let Some(value) = toml_value_after_key(content, "command") {
command = Some(unquote(value));
} else if let Some(value) = toml_value_after_key(content, "args") {
args = parse_flow_strings(value);
}
}
return match command {
Some(command) => Ok(Some(InstalledEntry {
path,
command,
args,
})),
None => Err(InstallError::BadExisting(format!(
"{} 的 [mcp_servers.wanning] 段缺 command,拒绝解读",
path.display()
))),
};
}
Ok(None)
}
fn toml_value_after_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
line.strip_prefix(key)?
.trim_start()
.strip_prefix('=')?
.trim_start()
.into()
}
fn read_hermes_config(path: PathBuf, text: &str) -> Result<Option<InstalledEntry>, InstallError> {
let lines: Vec<&str> = text.lines().collect();
for (index, line) in lines.iter().enumerate() {
if line.trim() != "wanning:" {
continue;
}
let key_indent = indent_of(line);
let mut end = index + 1;
while end < lines.len() {
let follow = lines[end];
if follow.trim().is_empty() || indent_of(follow) <= key_indent {
break;
}
end += 1;
}
return read_yamlish_entry(path, &lines[index + 1..end]);
}
Ok(None)
}
fn read_dsh_block(path: PathBuf, text: &str) -> Result<Option<InstalledEntry>, InstallError> {
let blocks = scan_patch_blocks(text)?;
let lines: Vec<&str> = text.lines().collect();
let span = blocks.iter().copied().find(|&(start, end)| {
lines[start..end]
.iter()
.any(|line| line.contains("id: wanning-gate"))
});
match span {
Some((start, end)) => read_yamlish_entry(path, &lines[start..end]),
None => Ok(None),
}
}
fn read_yamlish_entry(
path: PathBuf,
lines: &[&str],
) -> Result<Option<InstalledEntry>, InstallError> {
let mut command: Option<String> = None;
let mut args: Vec<String> = Vec::new();
let mut index = 0;
while index < lines.len() {
let content = lines[index].trim();
if let Some(value) = yaml_scalar_after_key(content, "command") {
command = Some(unquote(value));
} else if content == "args:" {
let base_indent = indent_of(lines[index]);
let mut block = Vec::new();
let mut scan = index + 1;
while scan < lines.len() {
let follow = lines[scan];
if follow.trim().is_empty() || indent_of(follow) <= base_indent {
break;
}
match follow.trim().strip_prefix("- ") {
Some(item) => block.push(unquote(item)),
None => break,
}
scan += 1;
}
args = block;
index = scan;
continue;
} else if let Some(value) = yaml_scalar_after_key(content, "args") {
args = parse_flow_strings(value);
}
index += 1;
}
match command {
Some(command) => Ok(Some(InstalledEntry {
path,
command,
args,
})),
None => Ok(None),
}
}
fn yaml_scalar_after_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
line.strip_prefix(key)?
.strip_prefix(':')?
.trim_start()
.into()
}
fn parse_flow_strings(text: &str) -> Vec<String> {
let Some(inner) = text
.trim()
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
else {
return Vec::new();
};
inner
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(unquote)
.collect()
}
fn unquote(text: &str) -> String {
let trimmed = text.trim();
if trimmed.len() >= 2 {
let bytes = trimmed.as_bytes();
let first = bytes[0];
let last = bytes[trimmed.len() - 1];
if (first == b'\'' || first == b'"') && first == last {
return trimmed[1..trimmed.len() - 1].to_string();
}
}
trimmed.to_string()
}
fn indent_of(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "布尔值",
Value::Number(_) => "数字",
Value::String(_) => "字符串",
Value::Array(_) => "数组",
Value::Object(_) => "对象",
}
}
fn read_optional(path: &Path) -> Result<Option<String>, InstallError> {
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(InstallError::Io(format!(
"读 {} 失败: {error}",
path.display()
))),
}
}
fn backup_path_for(path: &Path) -> PathBuf {
let mut name = path.file_name().expect("安装落点必有文件名").to_os_string();
name.push(".wanning.bak");
path.with_file_name(name)
}
fn artifact_for(options: &InstallOptions) -> Result<crate::Artifact, InstallError> {
generate_with(options.platform, options.resolved)
.map_err(|error| InstallError::Generate(error.message()))
}