mod builtins;
mod cmdtest;
mod config;
mod cron;
mod cron_daemon;
mod deps;
mod hostconfig;
mod inputs;
mod inspect;
mod packages;
mod ps;
pub mod remote;
mod runner;
mod runtime_daemon;
mod shell_emit;
mod spec_load;
mod unifier_events;
mod yaml_closure;
pub use config::{load_user_config, UserConfig};
pub use runner::run_jan;
pub use spec_load::{HostComputer, HostPlatform};
use std::collections::{BTreeMap, HashSet};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use rusqlite::Connection;
use serde::de::{self, Deserializer, Visitor};
use serde::Deserialize;
use std::fmt;
#[derive(Debug, Deserialize)]
pub struct RootSpec {
pub metadata: Option<Metadata>,
#[serde(default)]
pub commands: BTreeMap<String, CommandNode>,
}
#[derive(Debug, Deserialize)]
pub struct Metadata {
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct EnvSpec {
pub public: BTreeMap<String, String>,
pub private: Vec<String>,
pub pass: BTreeMap<String, String>,
}
impl EnvSpec {
pub fn is_empty(&self) -> bool {
self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
}
pub fn restricts_child_env(&self) -> bool {
!self.is_empty()
}
pub fn merge_from(&mut self, other: EnvSpec) {
for (k, v) in other.public {
self.public.insert(k, v);
}
for name in other.private {
if !self.private.iter().any(|p| p == &name) {
self.private.push(name);
}
}
for (k, v) in other.pass {
self.pass.insert(k, v);
}
}
pub fn validate(&self, path: &str) -> Result<()> {
for name in &self.private {
if name.trim().is_empty() {
bail!("command '{path}': env.private entry must not be empty");
}
}
for (env_name, pass_id) in &self.pass {
if env_name.trim().is_empty() {
bail!("command '{path}': env.pass key must not be empty");
}
if pass_id.trim().is_empty() {
bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
}
if self.private.iter().any(|p| p == env_name) {
bail!(
"command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
);
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for EnvSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Structured {
#[serde(default)]
public: BTreeMap<String, String>,
#[serde(default, deserialize_with = "deserialize_string_or_seq")]
private: Vec<String>,
#[serde(default)]
pass: BTreeMap<String, String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum EnvDe {
Flat(BTreeMap<String, String>),
Sections(Structured),
}
Ok(match EnvDe::deserialize(deserializer)? {
EnvDe::Flat(public) => Self {
public,
private: Vec::new(),
pass: BTreeMap::new(),
},
EnvDe::Sections(s) => Self {
public: s.public,
private: s.private,
pass: s.pass,
},
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IncludeLinkKind {
Yaml,
Script,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludeLink {
pub kind: IncludeLinkKind,
pub path: Option<String>,
pub url: Option<String>,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AliasesSpec {
pub names: Vec<String>,
pub shell: BTreeMap<String, String>,
}
impl AliasesSpec {
pub fn is_empty(&self) -> bool {
self.names.is_empty() && self.shell.is_empty()
}
pub fn merge_from(&mut self, other: Self) {
for n in other.names {
self.shell.remove(&n);
if !self.names.iter().any(|e| e == &n) {
self.names.push(n);
}
}
for (k, v) in other.shell {
self.names.retain(|n| n != &k);
self.shell.insert(k, v);
}
}
pub fn validate(&self, path: &str) -> Result<()> {
let mut seen = HashSet::new();
for name in &self.names {
if !is_safe_alias_name(name) {
bail!("command '{path}': alias name `{name}` must match [A-Za-z_][A-Za-z0-9_-]*");
}
if !seen.insert(name.clone()) {
bail!("command '{path}': duplicate alias name `{name}`");
}
}
for name in self.shell.keys() {
if !is_safe_alias_name(name) {
bail!("command '{path}': alias name `{name}` must match [A-Za-z_][A-Za-z0-9_-]*");
}
if !seen.insert(name.clone()) {
bail!(
"command '{path}': alias `{name}` is declared both as a jan name and a shell RHS"
);
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfigSpec {
pub shell: Option<ConfigShell>,
pub link: BTreeMap<String, ConfigLinkSource>,
pub apply: Vec<Vec<String>>,
pub deps: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigShell {
Path(String),
Inline(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigLinkSource {
Path(String),
Inline(String),
}
impl ConfigSpec {
pub fn is_empty(&self) -> bool {
self.shell.is_none()
&& self.link.is_empty()
&& self.apply.is_empty()
&& self.deps.is_empty()
}
pub fn merge_from(&mut self, other: Self) {
if other.shell.is_some() {
self.shell = other.shell;
}
for (k, v) in other.link {
self.link.insert(k, v);
}
self.apply.extend(other.apply);
for (k, v) in other.deps {
self.deps.insert(k, v);
}
}
pub fn validate(&self, path: &str) -> Result<()> {
if let Some(ConfigShell::Path(p)) = &self.shell {
let t = p.trim();
if t.is_empty() {
bail!("command '{path}': config.shell.path must not be empty");
}
if Path::new(t).is_absolute()
|| Path::new(t)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!(
"command '{path}': config.shell.path must be relative to the jan use root (no `..`)"
);
}
}
if let Some(ConfigShell::Inline(s)) = &self.shell {
if s.trim().is_empty() {
bail!("command '{path}': config.shell inline text must not be empty");
}
}
for (dest, src) in &self.link {
if dest.trim().is_empty() {
bail!("command '{path}': config.link destination must not be empty");
}
match src {
ConfigLinkSource::Path(p) => {
let p = p.trim();
if p.is_empty() {
bail!("command '{path}': config.link path for `{dest}` must not be empty");
}
if p.contains('\n') || p.contains('\r') {
bail!(
"command '{path}': config.link path for `{dest}` looks like file contents (contains newlines). Use a relative path (e.g. `config/init.el`), a multiline `|` / `content:` inline body, or upgrade jan so bare multiline strings are treated as inline"
);
}
if Path::new(p).is_absolute()
|| Path::new(p)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!(
"command '{path}': config.link path `{p}` must be relative to the jan use root (no `..`)"
);
}
}
ConfigLinkSource::Inline(body) => {
if body.is_empty() {
bail!(
"command '{path}': config.link inline body for `{dest}` must not be empty"
);
}
}
}
}
for (i, argv) in self.apply.iter().enumerate() {
if argv.is_empty() || argv.iter().all(|a| a.trim().is_empty()) {
bail!("command '{path}': config.apply[{i}] must be a non-empty argv list");
}
}
for bin in self.deps.keys() {
let bin = bin.trim();
if bin.is_empty() {
bail!("command '{path}': config.deps key must not be empty");
}
if bin.contains('/') || bin.contains('\\') {
bail!(
"command '{path}': config.deps `{bin}` must be a bare command name (no path)"
);
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for ConfigSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
shell: Option<RawShell>,
#[serde(default)]
link: BTreeMap<String, RawLink>,
#[serde(default)]
apply: Vec<Vec<String>>,
#[serde(default)]
deps: BTreeMap<String, Option<String>>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawShell {
PathMap { path: String },
Inline(String),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawLink {
PathMap {
path: String,
},
ContentMap {
content: String,
},
String(String),
}
let raw = Raw::deserialize(deserializer)?;
let shell = match raw.shell {
None => None,
Some(RawShell::Inline(s)) => Some(ConfigShell::Inline(s)),
Some(RawShell::PathMap { path }) => Some(ConfigShell::Path(path)),
};
let mut link = BTreeMap::new();
for (dest, src) in raw.link {
let src = match src {
RawLink::PathMap { path } => ConfigLinkSource::Path(path),
RawLink::ContentMap { content } => ConfigLinkSource::Inline(content),
RawLink::String(s) => {
if s.contains('\n') {
ConfigLinkSource::Inline(s)
} else {
ConfigLinkSource::Path(s)
}
}
};
link.insert(dest, src);
}
let mut deps = BTreeMap::new();
for (k, v) in raw.deps {
deps.insert(k, v.unwrap_or_default());
}
Ok(ConfigSpec {
shell,
link,
apply: raw.apply,
deps,
})
}
}
impl<'de> Deserialize<'de> for AliasesSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct AliasesVisitor;
impl<'de> Visitor<'de> for AliasesVisitor {
type Value = AliasesSpec;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter
.write_str("a string, a list of names, or a map of alias name to shell RHS")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
if value.trim().is_empty() {
Ok(AliasesSpec::default())
} else {
Ok(AliasesSpec {
names: vec![value.to_string()],
shell: BTreeMap::new(),
})
}
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
let mut names = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
if !s.trim().is_empty() {
names.push(s);
}
}
Ok(AliasesSpec {
names,
shell: BTreeMap::new(),
})
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: de::MapAccess<'de>,
{
let mut spec = AliasesSpec::default();
while let Some(key) = map.next_key::<String>()? {
let val: Option<String> = map.next_value()?;
match val {
Some(s) if !s.trim().is_empty() => {
spec.shell.insert(key, s);
}
_ => spec.names.push(key),
}
}
Ok(spec)
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AliasesSpec::default())
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AliasesSpec::default())
}
}
deserializer.deserialize_any(AliasesVisitor)
}
}
pub(crate) fn is_safe_alias_name(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct CommandNode {
#[serde(default)]
pub os: Vec<String>,
#[serde(default)]
pub computer: Vec<String>,
#[serde(default)]
pub about: String,
pub path: Option<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub env: EnvSpec,
#[serde(default)]
pub inputs: BTreeMap<String, crate::inputs::InputDef>,
#[serde(default, deserialize_with = "deserialize_string_or_seq")]
pub cron: Vec<String>,
#[serde(default)]
pub packages: PackagesSpec,
#[serde(default)]
pub tests: BTreeMap<String, CommandTest>,
#[serde(default)]
pub aliases: AliasesSpec,
#[serde(default)]
pub config: ConfigSpec,
#[serde(default)]
pub commands: BTreeMap<String, CommandNode>,
pub exec: Option<ExecSpec>,
#[serde(skip)]
pub source: Option<IncludeLink>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct CommandTest {
#[serde(default)]
pub given: String,
#[serde(default)]
pub when: String,
#[serde(default)]
pub then: String,
}
impl CommandTest {
pub fn validate(&self, path: &str, name: &str) -> Result<()> {
if !gherkin_test_name(name) {
bail!(
"command '{path}': test `{name}` must follow the given_…_when_…_then_… naming pattern"
);
}
if self.then.trim().is_empty() {
bail!("command '{path}': test `{name}` needs a non-empty `then:` script");
}
Ok(())
}
}
pub fn gherkin_test_name(name: &str) -> bool {
let n: String = name
.trim()
.to_ascii_lowercase()
.chars()
.map(|c| {
if c == '-' || c.is_whitespace() {
'_'
} else {
c
}
})
.collect();
let n = n
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
let Some(rest) = n.strip_prefix("given_") else {
return false;
};
let Some((given_body, after_when)) = rest.split_once("_when_") else {
return false;
};
let Some((when_body, then_body)) = after_when.split_once("_then_") else {
return false;
};
!given_body.is_empty() && !when_body.is_empty() && !then_body.is_empty()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct PackagesSpec {
#[serde(default)]
pub uv: Option<UvPackages>,
#[serde(default)]
pub pnpm: Option<PnpmPackages>,
#[serde(default)]
pub gradle: Option<GradlePackages>,
}
impl PackagesSpec {
pub fn is_empty(&self) -> bool {
self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
}
pub fn merge_from(&mut self, other: PackagesSpec) {
if other.uv.is_some() {
self.uv = other.uv;
}
if other.pnpm.is_some() {
self.pnpm = other.pnpm;
}
if other.gradle.is_some() {
self.gradle = other.gradle;
}
}
pub fn validate(&self, path: &str) -> Result<()> {
if let Some(uv) = &self.uv {
uv.validate(path)?;
}
if let Some(pnpm) = &self.pnpm {
pnpm.validate(path)?;
}
if let Some(gradle) = &self.gradle {
gradle.validate(path)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UvPackages {
pub deps: UvDeps,
pub python: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UvDeps {
List(Vec<String>),
Project(String),
Requirements(String),
}
impl UvPackages {
pub fn list(pkgs: Vec<String>) -> Self {
Self {
deps: UvDeps::List(pkgs),
python: None,
}
}
pub fn validate(&self, path: &str) -> Result<()> {
if let Some(py) = &self.python {
packages::parse_min_version_constraint(py)
.map_err(|e| anyhow::anyhow!("command '{path}': packages.uv.python: {e}"))?;
}
match &self.deps {
UvDeps::List(pkgs) => {
if pkgs.is_empty() {
bail!("command '{path}': packages.uv list must not be empty");
}
for p in pkgs {
if p.trim().is_empty() {
bail!("command '{path}': packages.uv entry must not be empty");
}
packages::check_pinned_requirement(p)
.map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
}
}
UvDeps::Project(p) | UvDeps::Requirements(p) => {
if p.trim().is_empty() {
bail!("command '{path}': packages.uv path must not be empty");
}
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for UvPackages {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MapForm {
#[serde(default)]
project: Option<String>,
#[serde(default)]
requirements: Option<String>,
#[serde(default, alias = "deps")]
packages: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_opt_stringish")]
python: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
List(Vec<String>),
Map(MapForm),
}
match Helper::deserialize(deserializer)? {
Helper::List(pkgs) => {
let pkgs: Vec<String> = pkgs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(UvPackages {
deps: UvDeps::List(pkgs),
python: None,
})
}
Helper::Map(m) => {
let project = m
.project
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let requirements = m
.requirements
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let packages = m.packages.map(|pkgs| {
pkgs.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
});
let python = m
.python
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let deps = match (project, requirements, packages) {
(Some(p), None, None) => UvDeps::Project(p),
(None, Some(r), None) => UvDeps::Requirements(r),
(None, None, Some(pkgs)) => UvDeps::List(pkgs),
_ => {
return Err(de::Error::custom(
"packages.uv map must set exactly one of `packages`, `project`, or `requirements`",
));
}
};
Ok(UvPackages { deps, python })
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PnpmPackages {
pub deps: PnpmDeps,
pub node: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PnpmDeps {
List(Vec<String>),
Project(String),
}
impl PnpmPackages {
pub fn list(pkgs: Vec<String>) -> Self {
Self {
deps: PnpmDeps::List(pkgs),
node: None,
}
}
pub fn validate(&self, path: &str) -> Result<()> {
if let Some(node) = &self.node {
packages::parse_min_version_constraint(node)
.map_err(|e| anyhow::anyhow!("command '{path}': packages.pnpm.node: {e}"))?;
}
match &self.deps {
PnpmDeps::List(pkgs) => {
if pkgs.is_empty() {
bail!("command '{path}': packages.pnpm list must not be empty");
}
for p in pkgs {
if p.trim().is_empty() {
bail!("command '{path}': packages.pnpm entry must not be empty");
}
packages::check_pinned_npm_spec(p)
.map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
}
}
PnpmDeps::Project(p) => {
if p.trim().is_empty() {
bail!("command '{path}': packages.pnpm path must not be empty");
}
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for PnpmPackages {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MapForm {
#[serde(default)]
project: Option<String>,
#[serde(default, alias = "deps")]
packages: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_opt_stringish")]
node: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
List(Vec<String>),
Map(MapForm),
}
match Helper::deserialize(deserializer)? {
Helper::List(pkgs) => {
let pkgs: Vec<String> = pkgs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(PnpmPackages {
deps: PnpmDeps::List(pkgs),
node: None,
})
}
Helper::Map(m) => {
let project = m
.project
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let packages = m.packages.map(|pkgs| {
pkgs.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
});
let node = m
.node
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let deps = match (project, packages) {
(Some(p), None) => PnpmDeps::Project(p),
(None, Some(pkgs)) => PnpmDeps::List(pkgs),
_ => {
return Err(de::Error::custom(
"packages.pnpm map must set exactly one of `packages` or `project`",
));
}
};
Ok(PnpmPackages { deps, node })
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GradlePackages {
pub deps: GradleDeps,
pub java: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GradleDeps {
List(Vec<String>),
Project(String),
}
impl GradlePackages {
pub fn list(pkgs: Vec<String>) -> Self {
Self {
deps: GradleDeps::List(pkgs),
java: None,
}
}
pub fn validate(&self, path: &str) -> Result<()> {
if let Some(java) = &self.java {
packages::parse_min_version_constraint(java)
.map_err(|e| anyhow::anyhow!("command '{path}': packages.gradle.java: {e}"))?;
}
match &self.deps {
GradleDeps::List(pkgs) => {
if pkgs.is_empty() {
bail!("command '{path}': packages.gradle list must not be empty");
}
for p in pkgs {
if p.trim().is_empty() {
bail!("command '{path}': packages.gradle entry must not be empty");
}
packages::check_pinned_maven_coord(p)
.map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
}
}
GradleDeps::Project(p) => {
if p.trim().is_empty() {
bail!("command '{path}': packages.gradle path must not be empty");
}
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for GradlePackages {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MapForm {
#[serde(default)]
project: Option<String>,
#[serde(default, alias = "deps")]
packages: Option<Vec<String>>,
#[serde(default, alias = "jdk", deserialize_with = "deserialize_opt_stringish")]
java: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
List(Vec<String>),
Map(MapForm),
}
match Helper::deserialize(deserializer)? {
Helper::List(pkgs) => {
let pkgs: Vec<String> = pkgs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(GradlePackages {
deps: GradleDeps::List(pkgs),
java: None,
})
}
Helper::Map(m) => {
let project = m
.project
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let packages = m.packages.map(|pkgs| {
pkgs.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
});
let java = m
.java
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let deps = match (project, packages) {
(Some(p), None) => GradleDeps::Project(p),
(None, Some(pkgs)) => GradleDeps::List(pkgs),
_ => {
return Err(de::Error::custom(
"packages.gradle map must set exactly one of `packages` or `project`",
));
}
};
Ok(GradlePackages { deps, java })
}
}
}
}
pub(crate) fn deserialize_opt_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
struct Stringish;
impl<'de> Visitor<'de> for Stringish {
type Value = Option<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or number version constraint, or null")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let t = value.trim();
if t.is_empty() {
Ok(None)
} else {
Ok(Some(t.to_string()))
}
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Some(value.to_string()))
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Some(value.to_string()))
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: de::Error,
{
let s = if (value.fract()).abs() < f64::EPSILON {
format!("{}", value as i64)
} else {
let s = format!("{value}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
};
Ok(Some(s))
}
}
deserializer.deserialize_any(Stringish)
}
pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrSeq;
impl<'de> Visitor<'de> for StringOrSeq {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or a sequence of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
if value.trim().is_empty() {
Ok(Vec::new())
} else {
Ok(vec![value.to_string()])
}
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
let mut out = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
if !s.trim().is_empty() {
out.push(s);
}
}
Ok(out)
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Vec::new())
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Vec::new())
}
}
deserializer.deserialize_any(StringOrSeq)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalInclude {
pub path: String,
pub sha256: Option<String>,
pub argv: Vec<String>,
pub passthrough: bool,
}
impl LocalInclude {
pub fn from_path(path: impl Into<String>) -> Self {
Self {
path: path.into(),
sha256: None,
argv: Vec::new(),
passthrough: false,
}
}
pub fn is_yaml(&self) -> bool {
let lower = self.path.to_ascii_lowercase();
lower.ends_with(".yaml") || lower.ends_with(".yml")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IncludeRef {
Local(LocalInclude),
Remote(RemoteInclude),
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RemoteInclude {
pub url: String,
pub sha256: String,
#[serde(default)]
pub ttl: Option<u64>,
}
impl IncludeRef {
pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote(_))
}
pub fn local_path(&self) -> Option<&str> {
match self {
Self::Local(l) => Some(l.path.as_str()),
Self::Remote(_) => None,
}
}
pub fn cycle_token(&self) -> String {
match self {
Self::Local(l) => match &l.sha256 {
Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
None => l.path.clone(),
},
Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
}
}
}
impl<'de> Deserialize<'de> for IncludeRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LocalMap {
path: String,
#[serde(default)]
sha256: Option<String>,
#[serde(default)]
argv: Vec<String>,
#[serde(default)]
passthrough: bool,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
Path(String),
Local(LocalMap),
Remote(RemoteInclude),
}
match Helper::deserialize(deserializer)? {
Helper::Path(path) => {
let path = path.trim();
if path.is_empty() {
return Err(de::Error::custom("include path must not be empty"));
}
Ok(IncludeRef::Local(LocalInclude::from_path(path)))
}
Helper::Local(m) => {
let path = m.path.trim();
if path.is_empty() {
return Err(de::Error::custom("include.path must not be empty"));
}
let sha256 = m
.sha256
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(IncludeRef::Local(LocalInclude {
path: path.to_string(),
sha256,
argv: m.argv,
passthrough: m.passthrough,
}))
}
Helper::Remote(r) => {
if r.url.trim().is_empty() {
return Err(de::Error::custom("include.url must not be empty"));
}
if r.sha256.trim().is_empty() {
return Err(de::Error::custom(
"include.sha256 is required with include.url",
));
}
Ok(IncludeRef::Remote(r))
}
}
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ExecSpec {
#[serde(default)]
pub argv: Vec<String>,
#[serde(default)]
pub passthrough: bool,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub kotlin: Option<String>,
#[serde(default)]
pub python: Option<String>,
#[serde(default)]
pub node: Option<String>,
#[serde(default)]
pub bash: Option<String>,
#[serde(default)]
pub sh: Option<String>,
#[serde(default)]
pub zsh: Option<String>,
#[serde(default, alias = "cat")]
pub text: Option<String>,
#[serde(default)]
pub sha256: Option<String>,
#[serde(default)]
pub ttl: Option<u64>,
}
impl ExecSpec {
pub fn is_remote(&self) -> bool {
self.url
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_local_file(&self) -> bool {
self.file
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_kotlin(&self) -> bool {
self.kotlin
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_python(&self) -> bool {
self.python
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_node(&self) -> bool {
self.node
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_bash(&self) -> bool {
self.bash
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_sh(&self) -> bool {
self.sh
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_zsh(&self) -> bool {
self.zsh
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn is_text(&self) -> bool {
self.text
.as_deref()
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
pub fn literal_text(&self) -> Option<&str> {
self.text
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
}
pub fn is_language_source(&self) -> bool {
self.is_kotlin()
|| self.is_python()
|| self.is_node()
|| self.is_bash()
|| self.is_sh()
|| self.is_zsh()
}
pub fn kotlin_value_is_path(raw: &str) -> bool {
Self::single_line_ext(raw, &[".kt", ".kts"])
}
pub fn python_value_is_path(raw: &str) -> bool {
Self::single_line_ext(raw, &[".py"])
}
pub fn node_value_is_path(raw: &str) -> bool {
Self::single_line_ext(raw, &[".js", ".mjs", ".cjs"])
}
pub fn bash_value_is_path(raw: &str) -> bool {
Self::single_line_ext(raw, &[".sh", ".bash"])
}
pub fn sh_value_is_path(raw: &str) -> bool {
Self::single_line_ext(raw, &[".sh"])
}
pub fn zsh_value_is_path(raw: &str) -> bool {
Self::single_line_ext(raw, &[".zsh", ".sh"])
}
fn single_line_ext(raw: &str, exts: &[&str]) -> bool {
let t = raw.trim();
if t.is_empty() || t.lines().nth(1).is_some() {
return false;
}
let lower = t.to_ascii_lowercase();
exts.iter().any(|e| lower.ends_with(e))
}
pub fn kotlin_is_path(&self) -> bool {
self.kotlin
.as_deref()
.map(Self::kotlin_value_is_path)
.unwrap_or(false)
}
pub fn validate(&self, path: &str) -> Result<()> {
let url = self.url.as_deref().map(str::trim).filter(|s| !s.is_empty());
let file = self
.file
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let kotlin = self
.kotlin
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let python = self
.python
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let node = self
.node
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let bash = self
.bash
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let sh = self.sh.as_deref().map(str::trim).filter(|s| !s.is_empty());
let zsh = self.zsh.as_deref().map(str::trim).filter(|s| !s.is_empty());
let text = self
.text
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let hash = self
.sha256
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let exclusive = [
("url", url),
("file", file),
("kotlin", kotlin),
("python", python),
("node", node),
("bash", bash),
("sh", sh),
("zsh", zsh),
("text", text),
];
let set: Vec<(&str, &str)> = exclusive
.iter()
.copied()
.filter_map(|(n, v)| v.map(|s| (n, s)))
.collect();
if set.len() > 1 {
bail!(
"command '{path}': exec cannot combine `url`, `file`, `kotlin`, `python`, `node`, `bash`, `sh`, `zsh`, and `text`"
);
}
const LANG: &[&str] = &["kotlin", "python", "node", "bash", "sh", "zsh"];
if hash.is_some() && set.iter().any(|(n, _)| LANG.contains(n) || *n == "text") {
bail!(
"command '{path}': exec.sha256 is not supported with exec.kotlin / exec.python / exec.node / exec.bash / exec.sh / exec.zsh / exec.text"
);
}
match set.first().copied() {
Some(("url", _)) if hash.is_some() => Ok(()),
Some(("url", _)) => {
bail!("command '{path}': exec.sha256 is required with exec.url")
}
Some(("file", _)) => Ok(()),
Some(("text", _)) => Ok(()),
Some(("kotlin", k)) => {
if Self::kotlin_value_is_path(k) {
return Ok(());
}
if !k.contains("fun ") && !k.contains("fun\t") {
bail!(
"command '{path}': exec.kotlin inline source must contain a `fun` \
(or set a single-line `.kt` / `.kts` path)"
);
}
Ok(())
}
Some((label, src)) if LANG.contains(&label) => {
let is_path = match label {
"python" => Self::python_value_is_path(src),
"node" => Self::node_value_is_path(src),
"bash" => Self::bash_value_is_path(src),
"sh" => Self::sh_value_is_path(src),
"zsh" => Self::zsh_value_is_path(src),
_ => false,
};
if is_path {
return Ok(());
}
if src.len() < 2 {
bail!("command '{path}': exec.{label} inline source is empty");
}
Ok(())
}
None if hash.is_some() => {
bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
}
None => {
if self.argv.is_empty() {
bail!(
"command '{path}': exec.argv must not be empty (or set exec.url / exec.file / exec.kotlin / exec.python / exec.node / exec.bash / exec.sh / exec.zsh / exec.text)"
);
}
Ok(())
}
_ => unreachable!("modes > 1 checked above"),
}
}
}
impl CommandNode {
pub fn is_leaf_exec(&self) -> bool {
self.exec.is_some()
}
pub fn validate(&self, path: &str) -> Result<()> {
if self.exec.is_some() && !self.commands.is_empty() {
bail!("command '{path}' cannot define both `exec` and nested `commands`");
}
if let Some(ref e) = self.exec {
e.validate(path)?;
}
self.aliases.validate(path)?;
if !self.aliases.names.is_empty() {
let is_jan_target = self
.commands
.get("run")
.map(|r| r.exec.is_some())
.unwrap_or(false)
|| (self.exec.is_some() && self.commands.is_empty());
if !is_jan_target {
bail!(
"command '{path}': `aliases` names (not map RHS) require this node to be a jan alias target (`run` with exec, or a leaf `exec`)"
);
}
}
self.config.validate(path)?;
self.env.validate(path)?;
self.packages.validate(path)?;
for (name, t) in &self.tests {
t.validate(path, name)?;
}
for (name, def) in &self.inputs {
def.validate(name)
.map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
}
for (name, child) in &self.commands {
let p = if path.is_empty() {
name.clone()
} else {
format!("{path} {name}")
};
child.validate(&p)?;
}
Ok(())
}
}
pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
for (name, node) in overlay.commands {
match base.commands.get_mut(&name) {
Some(existing) => merge_command_node(existing, node)?,
None => {
base.commands.insert(name, node);
}
}
}
Ok(())
}
fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
if src.exec.is_some() && !src.commands.is_empty() {
bail!("merge overlay: command cannot define both `exec` and nested `commands`");
}
if !src.os.is_empty() {
dst.os = src.os;
}
if !src.computer.is_empty() {
dst.computer = src.computer;
}
if !src.about.trim().is_empty() {
dst.about = src.about;
}
if src.path.is_some() {
dst.path = src.path;
}
if !src.dependencies.is_empty() {
dst.dependencies = src.dependencies;
}
if !src.requires.is_empty() {
dst.requires = src.requires;
}
if !src.cron.is_empty() {
dst.cron = src.cron;
}
if !src.env.is_empty() {
dst.env.merge_from(src.env);
}
for (k, v) in src.inputs {
dst.inputs.insert(k, v);
}
for (k, v) in src.tests {
dst.tests.insert(k, v);
}
dst.aliases.merge_from(src.aliases);
dst.config.merge_from(src.config);
if let Some(exec) = src.exec {
dst.exec = Some(exec);
dst.commands.clear();
return Ok(());
}
if !src.commands.is_empty() {
dst.exec = None;
for (k, child) in src.commands {
match dst.commands.get_mut(&k) {
Some(existing) => merge_command_node(existing, child)?,
None => {
dst.commands.insert(k, child);
}
}
}
}
Ok(())
}
pub fn validate_spec(spec: &RootSpec) -> Result<()> {
for (name, node) in &spec.commands {
node.validate(name)?;
}
Ok(())
}
pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
}
pub fn load_spec(path: &Path) -> Result<RootSpec> {
spec_load::load_spec_from_path(path, HostPlatform::detect())
}
pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
if let Some(b) = override_branch {
if !b.is_empty() {
return b.to_string();
}
}
if let Ok(v) = std::env::var("JAN_BRANCH") {
if !v.is_empty() {
return v;
}
}
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "(no-git)".to_string(),
}
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
fn is_help_leaf(name: &str, child: &CommandNode) -> bool {
name == "help" && child.exec.is_some() && child.commands.is_empty()
}
fn has_run_leaf(node: &CommandNode) -> bool {
node.commands
.get("run")
.map(|r| r.exec.is_some())
.unwrap_or(false)
}
fn is_listed_subcommand(name: &str, child: &CommandNode) -> bool {
if is_help_leaf(name, child) {
return false;
}
has_run_leaf(child)
|| !child.aliases.is_empty()
|| !child.config.is_empty()
|| child.exec.is_some()
|| child
.commands
.iter()
.any(|(n, c)| is_listed_subcommand(n, c))
}
fn jan_invocation_for_node(bin: &str, chain: &[String], node: &CommandNode) -> String {
let mut parts: Vec<String> = std::iter::once(bin.to_string())
.chain(chain.iter().cloned())
.collect();
if has_run_leaf(node) {
parts.push("run".into());
}
parts.join(" ")
}
fn help_alias_lines(bin: &str, chain: &[String], node: &CommandNode) -> Vec<(String, String)> {
let mut lines = BTreeMap::new();
let target = jan_invocation_for_node(bin, chain, node);
for name in &node.aliases.names {
lines.insert(name.clone(), format!("same as `{target}`"));
}
for (name, rhs) in &node.aliases.shell {
lines.insert(name.clone(), rhs.clone());
}
lines.into_iter().collect()
}
fn subcommand_blurb(child: &CommandNode) -> String {
let about = first_line(&child.about);
if !about.is_empty() {
return about;
}
if !child.aliases.is_empty() {
return "shell aliases".to_string();
}
if !child.config.is_empty() {
return "host configuration".to_string();
}
if has_run_leaf(child) {
return "run".to_string();
}
String::new()
}
fn append_help_aliases(out: &mut String, bin: &str, chain: &[String], node: Option<&CommandNode>) {
let Some(n) = node else {
return;
};
let lines = help_alias_lines(bin, chain, n);
if lines.is_empty() {
return;
}
out.push('\n');
out.push_str("Aliases (`jan alias`):\n");
for (name, rhs) in lines {
out.push_str(&format!(" {name} — {}\n", first_line(&rhs)));
}
}
fn append_help_config(out: &mut String, node: Option<&CommandNode>) {
let Some(n) = node else {
return;
};
if n.config.is_empty() {
return;
}
out.push('\n');
out.push_str("Host configuration (`jan config`):\n");
if let Some(shell) = &n.config.shell {
match shell {
ConfigShell::Path(p) => {
out.push_str(&format!(" shell — path: {p}\n"));
}
ConfigShell::Inline(t) => {
let preview = first_line(t);
if preview.is_empty() {
out.push_str(" shell — inline\n");
} else {
out.push_str(&format!(" shell — inline: {preview}\n"));
}
}
}
}
for (dest, src) in &n.config.link {
match src {
ConfigLinkSource::Path(p) => {
out.push_str(&format!(" link — {dest} ← path: {p}\n"));
}
ConfigLinkSource::Inline(body) => {
let n_lines = body.lines().count();
out.push_str(&format!(" link — {dest} ← inline ({n_lines} lines)\n"));
}
}
}
if !n.config.apply.is_empty() {
let n_apply = n.config.apply.len();
out.push_str(&format!(
" apply — {n_apply} argv list(s) (`jan config apply`)\n"
));
}
if !n.config.deps.is_empty() {
let n_deps = n.config.deps.len();
out.push_str(&format!(
" deps — {n_deps} host tool(s) (`jan config deps`)\n"
));
}
}
fn node_at_chain<'a>(spec: &'a RootSpec, chain: &[String]) -> Option<&'a CommandNode> {
let mut map = &spec.commands;
let mut node = None;
for seg in chain {
let next = map.get(seg)?;
node = Some(next);
map = &next.commands;
}
node
}
fn command_help_text(
spec: &RootSpec,
chain: &[String],
node: Option<&CommandNode>,
) -> Option<String> {
let n = node?;
if let Some(t) = n.exec.as_ref().and_then(ExecSpec::literal_text) {
return Some(t.to_string());
}
if let Some(t) = n
.commands
.get("help")
.and_then(|h| h.exec.as_ref())
.and_then(ExecSpec::literal_text)
{
return Some(t.to_string());
}
if chain.last().map(String::as_str) == Some("run") && chain.len() >= 2 {
let parent = node_at_chain(spec, &chain[..chain.len() - 1])?;
return parent
.commands
.get("help")
.and_then(|h| h.exec.as_ref())
.and_then(ExecSpec::literal_text)
.map(str::to_string);
}
None
}
pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
let mut out = String::new();
let bin = spec
.metadata
.as_ref()
.and_then(|m| m.name.as_deref())
.unwrap_or("jan");
let full_cmd = if chain.is_empty() {
bin.to_string()
} else {
format!("{} {}", bin, chain.join(" "))
};
let (about, children, exec) = match node {
Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
None => ("", &spec.commands, None),
};
if chain.is_empty() {
if let Some(meta) = &spec.metadata {
if let Some(desc) = &meta.description {
out.push_str(desc.trim());
out.push_str("\n\n");
}
}
}
let help_doc = command_help_text(spec, chain, node);
if let Some(doc) = &help_doc {
out.push_str(doc);
out.push_str("\n\n");
} else if !about.is_empty() {
out.push_str(about.trim());
out.push_str("\n\n");
}
let listed: Vec<(&String, &CommandNode)> = children
.iter()
.filter(|(name, child)| is_listed_subcommand(name, child))
.collect();
let has_aliases = node
.map(|n| !help_alias_lines(bin, chain, n).is_empty())
.unwrap_or(false);
let has_config = node.map(|n| !n.config.is_empty()).unwrap_or(false);
if exec.is_some() && children.is_empty() {
if help_doc.is_none() {
out.push_str("This command runs an external program (see spec `exec.argv`).\n");
}
append_help_aliases(&mut out, bin, chain, node);
append_help_config(&mut out, node);
append_help_inputs_and_tests(&mut out, spec, chain, node);
return out;
}
if !listed.is_empty() {
out.push_str("Subcommands:\n");
for (name, child) in &listed {
let blurb = subcommand_blurb(child);
let line = if blurb.is_empty() {
format!(" {name}\n")
} else {
format!(" {name} — {blurb}\n")
};
out.push_str(&line);
}
out.push('\n');
if listed.iter().any(|(n, _)| n.as_str() != "run") {
out.push_str(&format!(
"Use `{} --help` for more about a subcommand.\n",
full_cmd
));
}
append_help_aliases(&mut out, bin, chain, node);
append_help_config(&mut out, node);
append_help_inputs_and_tests(&mut out, spec, chain, node);
} else if exec.is_none() && help_doc.is_none() && !has_aliases && !has_config {
out.push_str("(No subcommands defined.)\n");
append_help_aliases(&mut out, bin, chain, node);
append_help_config(&mut out, node);
append_help_inputs_and_tests(&mut out, spec, chain, node);
} else {
append_help_aliases(&mut out, bin, chain, node);
append_help_config(&mut out, node);
append_help_inputs_and_tests(&mut out, spec, chain, node);
}
if chain.is_empty() && node.is_none() {
out.push_str(
"\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `config`, `list`, `search`, `show`, `validate`, `audit`, `cron`, `test`.\n",
);
}
out
}
fn append_help_inputs_and_tests(
out: &mut String,
spec: &RootSpec,
chain: &[String],
node: Option<&CommandNode>,
) {
let defs = inputs::collect_chain_inputs(chain, spec);
if !defs.is_empty() {
out.push('\n');
out.push_str(&inputs::format_inputs_help(&defs));
}
let n = match node {
Some(n) => cmdtest::count_tests(n),
None => spec.commands.values().map(cmdtest::count_tests).sum(),
};
if n > 0 {
let hint = if chain.is_empty() {
"jan test".to_string()
} else {
format!("jan test {}", chain.join(" "))
};
out.push_str(&format!("\n{n} test(s) — run with `{hint}`.\n"));
}
}
#[derive(Debug, Clone)]
pub struct SpecRootIdentity {
pub spec_dir: String,
pub root_yaml: String,
}
pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
let cfg = config::load_user_config().context("load user config")?;
let Some(dir_s) = cfg
.jan_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
else {
bail!(
"no preferred jan directory configured\n\
Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
);
};
let dir = PathBuf::from(dir_s);
if !dir.is_dir() {
bail!(
"preferred jan directory does not exist: {}\n\
Fix the path or run `jan use <DIR>` again (config: {})",
dir.display(),
config::config_path().display()
);
}
let root = cfg
.spec_root
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("scripts.spec.yaml");
resolve_spec_dir_entry(&dir, root)
}
pub fn resolve_spec_dir_entry(
spec_dir: &Path,
root_yaml: &str,
) -> Result<(PathBuf, SpecRootIdentity)> {
let rel = Path::new(root_yaml);
if rel.is_absolute() {
bail!("entry YAML must be a relative file name, not an absolute path");
}
if rel
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("entry YAML must not contain `..`");
}
let normal_only = rel
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)));
let n = rel
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.count();
if !normal_only || n != 1 {
bail!("entry YAML must be a single file name inside the jan directory");
}
let dir = spec_dir
.canonicalize()
.with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
if !dir.is_dir() {
bail!("not a directory: {}", dir.display());
}
let spec_path = dir.join(rel);
if !spec_path.is_file() {
bail!(
"spec entry not found: {} (under {})\n\
Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
spec_path.display(),
dir.display()
);
}
let identity = SpecRootIdentity {
spec_dir: dir.to_string_lossy().into_owned(),
root_yaml: rel
.file_name()
.expect("relative root has file_name")
.to_string_lossy()
.into_owned(),
};
Ok((spec_path, identity))
}
pub struct RunContext<'a> {
pub cwd: &'a Path,
pub db_path: Option<&'a Path>,
pub branch: String,
pub no_log: bool,
pub spec_root: &'a SpecRootIdentity,
}
fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
if argv.len() != 3 {
return false;
}
let prog = Path::new(&argv[0])
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(argv[0].as_str());
let is_shell = matches!(
prog,
"bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
);
is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
}
fn shell_passthrough_argv0(chain: &[String]) -> String {
chain
.iter()
.rev()
.find(|s| s.as_str() != "run")
.cloned()
.or_else(|| chain.last().cloned())
.unwrap_or_else(|| "jan".to_string())
}
fn try_warm_language_exec(
exec: &ExecSpec,
argv: &[String],
program: &Path,
pkg_envs: &packages::EnsuredEnvs,
env_spec: &EnvSpec,
path_override: Option<&str>,
cwd: &Path,
) -> Result<Option<i32>> {
use runtime_daemon::{try_run_warm, JobRequest, JobSource, RuntimeLang, WorkerKey};
if !runtime_daemon::runtime_enabled() {
return Ok(None);
}
let path_ov = path_override.map(|s| s.to_string());
let mut child_env = deps::resolve_child_env(env_spec, path_ov)?;
let (key, job) = if exec.is_python() {
let src = exec.python.as_deref().unwrap().trim();
let env_root = pkg_envs
.uv
.as_ref()
.and_then(|u| u.bin_dir.parent())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let (kind, value, main_args) = if ExecSpec::python_value_is_path(src) {
let path = argv.get(1).cloned().unwrap_or_default();
("path", path, argv.get(2..).unwrap_or(&[]).to_vec())
} else {
let code = argv.get(2).cloned().unwrap_or_else(|| src.to_string());
("inline", code, argv.get(3..).unwrap_or(&[]).to_vec())
};
let key = WorkerKey {
lang: RuntimeLang::Python,
interpreter: program.to_string_lossy().into_owned(),
env_root,
node_path: None,
};
let job = JobRequest {
cwd: cwd.to_string_lossy().into_owned(),
env: child_env,
source: JobSource {
kind: kind.into(),
value,
classpath: None,
main_class: None,
java: None,
},
argv: main_args,
shell: None,
argv0: None,
};
(key, job)
} else if exec.is_node() {
let src = exec.node.as_deref().unwrap().trim();
let env_root = pkg_envs
.pnpm
.as_ref()
.map(|p| p.modules_dir.to_string_lossy().into_owned())
.unwrap_or_default();
let node_path = packages::node_path_for(pkg_envs);
if let Some(np) = &node_path {
child_env.insert("NODE_PATH".into(), np.clone());
}
let (kind, value, main_args) = if ExecSpec::node_value_is_path(src) {
let path = argv.get(1).cloned().unwrap_or_default();
("path", path, argv.get(2..).unwrap_or(&[]).to_vec())
} else {
let code = argv.get(2).cloned().unwrap_or_else(|| src.to_string());
("inline", code, argv.get(3..).unwrap_or(&[]).to_vec())
};
let key = WorkerKey {
lang: RuntimeLang::Node,
interpreter: program.to_string_lossy().into_owned(),
env_root,
node_path,
};
let job = JobRequest {
cwd: cwd.to_string_lossy().into_owned(),
env: child_env,
source: JobSource {
kind: kind.into(),
value,
classpath: None,
main_class: None,
java: None,
},
argv: main_args,
shell: None,
argv0: None,
};
(key, job)
} else if exec.is_bash() || exec.is_sh() || exec.is_zsh() {
let (lang, shell_name, field) = if exec.is_bash() {
(
RuntimeLang::Bash,
"bash",
exec.bash.as_deref().unwrap().trim(),
)
} else if exec.is_zsh() {
(
RuntimeLang::Zsh,
"zsh",
exec.zsh.as_deref().unwrap().trim(),
)
} else {
(RuntimeLang::Sh, "sh", exec.sh.as_deref().unwrap().trim())
};
let is_path = match lang {
RuntimeLang::Bash => ExecSpec::bash_value_is_path(field),
RuntimeLang::Zsh => ExecSpec::zsh_value_is_path(field),
_ => ExecSpec::sh_value_is_path(field),
};
let (kind, value, main_args, argv0) = if is_path {
let path = argv.get(1).cloned().unwrap_or_default();
("path", path, argv.get(2..).unwrap_or(&[]).to_vec(), None)
} else {
let body = argv.get(2).cloned().unwrap_or_else(|| field.to_string());
let argv0 = argv.get(3).cloned();
("inline", body, argv.get(4..).unwrap_or(&[]).to_vec(), argv0)
};
let key = WorkerKey {
lang,
interpreter: program.to_string_lossy().into_owned(),
env_root: String::new(),
node_path: None,
};
let job = JobRequest {
cwd: cwd.to_string_lossy().into_owned(),
env: child_env,
source: JobSource {
kind: kind.into(),
value,
classpath: None,
main_class: None,
java: None,
},
argv: main_args,
shell: Some(shell_name.into()),
argv0,
};
(key, job)
} else if exec.is_kotlin() {
let field = exec.kotlin.as_deref().unwrap().trim();
if ExecSpec::kotlin_value_is_path(field) && field.to_ascii_lowercase().ends_with(".kts") {
return Ok(None);
}
if argv.first().is_some_and(|a| {
Path::new(a)
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.starts_with("kotlinc"))
}) {
return Ok(None);
}
let env_root = pkg_envs
.gradle
.as_ref()
.map(|g| g.lib_dir.to_string_lossy().into_owned())
.unwrap_or_default();
let key = WorkerKey {
lang: RuntimeLang::Kotlin,
interpreter: which_python_for_kotlin_worker(),
env_root,
node_path: None,
};
let job = JobRequest {
cwd: cwd.to_string_lossy().into_owned(),
env: child_env,
source: JobSource {
kind: "argv".into(),
value: String::new(),
classpath: None,
main_class: None,
java: None,
},
argv: argv.to_vec(),
shell: None,
argv0: None,
};
(key, job)
} else {
return Ok(None);
};
try_run_warm(&key, &job)
}
fn which_python_for_kotlin_worker() -> String {
crate::deps::resolve_program("python3", &[])
.or_else(|_| crate::deps::resolve_program("python", &[]))
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "python3".into())
}
pub fn run_matched(
spec: &RootSpec,
chain: &[String],
node: &CommandNode,
trailing: &[OsString],
ctx: &RunContext<'_>,
) -> Result<i32> {
let exec = match &node.exec {
Some(e) => e,
None => {
let help = format_help(spec, chain, Some(node));
print!("{help}");
bail!("missing subcommand");
}
};
exec.validate(&chain.join(" "))?;
if exec.is_text() {
let body = exec.literal_text().unwrap_or("");
println!("{body}");
return Ok(0);
}
let input_defs = inputs::collect_chain_inputs(chain, spec);
let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing, Some(ctx.cwd))?;
let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
for a in &exec.argv {
argv.push(inputs::interpolate(a, &input_vals)?);
}
if exec.is_remote() {
let url = exec.url.as_deref().unwrap().trim();
let hash = exec.sha256.as_deref().unwrap().trim();
let mut opts = remote::FetchOpts::new();
if let Some(ttl) = exec.ttl {
opts = opts.with_ttl(ttl);
}
let cached = remote::fetch_verified(url, hash, &opts, true)?;
argv.push(cached.to_string_lossy().into_owned());
} else if exec.is_local_file() {
let rel = exec.file.as_deref().unwrap().trim();
let use_root = Path::new(&ctx.spec_root.spec_dir);
let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
if let Some(hash) = exec
.sha256
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
remote::verify_file_sha256(&resolved, hash)
.with_context(|| format!("verify exec.file `{rel}`"))?;
}
argv.push(resolved.to_string_lossy().into_owned());
} else if exec.is_language_source() {
} else if argv.is_empty() {
bail!("exec.argv must not be empty");
}
if exec.passthrough {
let mut rest = rest;
if rest.first().is_some_and(|a| a == "--") {
rest = rest[1..].to_vec();
}
if shell_inline_c_needs_argv0(&argv) {
argv.push(shell_passthrough_argv0(chain));
}
for a in &rest {
argv.push(a.to_string_lossy().into_owned());
}
} else if !rest.is_empty() {
let preview = rest
.iter()
.take(3)
.map(|s| s.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ");
bail!(
"unexpected trailing arguments: {preview}{}",
if rest.len() > 3 { "…" } else { "" }
);
}
let cmd_path = if chain.is_empty() {
"(root)".to_string()
} else {
chain.join(" ")
};
let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
deps::check_requires(&requires)?;
let pkgs = packages::collect_chain_packages(chain, spec);
let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
if exec.is_kotlin() {
let rel = exec.kotlin.as_deref().unwrap().trim();
let use_root = Path::new(&ctx.spec_root.spec_dir);
let main_args = std::mem::take(&mut argv);
argv = packages::prepare_kotlin_argv(use_root, rel, &pkg_envs, &main_args)?;
} else if exec.is_python() {
let src = exec.python.as_deref().unwrap().trim();
let use_root = Path::new(&ctx.spec_root.spec_dir);
let main_args = std::mem::take(&mut argv);
argv = packages::prepare_python_argv(use_root, src, &main_args)?;
} else if exec.is_node() {
let src = exec.node.as_deref().unwrap().trim();
let use_root = Path::new(&ctx.spec_root.spec_dir);
let main_args = std::mem::take(&mut argv);
argv = packages::prepare_node_argv(use_root, src, &main_args)?;
} else if exec.is_bash() || exec.is_sh() || exec.is_zsh() {
let (kind, src) = if exec.is_bash() {
(
packages::ShellKind::Bash,
exec.bash.as_deref().unwrap().trim(),
)
} else if exec.is_zsh() {
(
packages::ShellKind::Zsh,
exec.zsh.as_deref().unwrap().trim(),
)
} else {
(packages::ShellKind::Sh, exec.sh.as_deref().unwrap().trim())
};
let use_root = Path::new(&ctx.spec_root.spec_dir);
let main_args = std::mem::take(&mut argv);
let argv0 = shell_passthrough_argv0(chain);
argv = packages::prepare_shell_argv(kind, use_root, src, &argv0, &main_args)?;
} else {
packages::inject_jvm_classpath(&mut argv, &pkg_envs);
}
let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
let mut env_spec = deps::collect_chain_env(chain, spec);
for value in env_spec.public.values_mut() {
*value = inputs::interpolate(value, &input_vals)?;
}
deps::check_private_env(&env_spec.private)?;
let mut path_override = if !path_dirs.is_empty() {
Some(deps::prepend_path_env(&path_dirs)?)
} else {
None
};
if !pkg_envs.is_empty() {
path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
}
if let Some(node_path) = packages::node_path_for(&pkg_envs) {
env_spec
.public
.entry("NODE_PATH".to_string())
.or_insert(node_path);
}
if let Some(classpath) = packages::classpath_for(&pkg_envs) {
env_spec
.public
.entry("CLASSPATH".to_string())
.or_insert(classpath);
}
let mut c = Command::new(&program);
if argv.len() > 1 {
c.args(&argv[1..]);
}
c.current_dir(ctx.cwd);
deps::apply_process_env(&mut c, &env_spec, path_override.clone())?;
let warm_code = try_warm_language_exec(
exec, &argv, &program, &pkg_envs, &env_spec, path_override.as_deref(), ctx.cwd,
)?;
let code = if let Some(code) = warm_code {
code
} else {
let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
status.code().unwrap_or(255)
};
if !ctx.no_log {
if let Some(db) = ctx.db_path {
log_invocation(
db,
&ctx.branch,
ctx.cwd,
&cmd_path,
&argv,
code,
ctx.spec_root,
)?;
}
}
Ok(code)
}
fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
let cols: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<std::result::Result<_, _>>()?;
if !cols.iter().any(|c| c == "spec_root_id") {
conn.execute(
"ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
[],
)?;
}
Ok(())
}
fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
let ts = unix_ts();
conn.execute(
r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
)?;
let id: i64 = conn.query_row(
"SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
[&spec.spec_dir, &spec.root_yaml],
|r| r.get(0),
)?;
Ok(id)
}
fn log_invocation(
db_path: &Path,
branch: &str,
cwd: &Path,
command_path: &str,
argv: &[String],
exit_code: i32,
spec_root: &SpecRootIdentity,
) -> Result<()> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
conn.execute_batch(
r"
CREATE TABLE IF NOT EXISTS spec_roots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spec_dir TEXT NOT NULL,
root_yaml TEXT NOT NULL,
last_used_ts TEXT NOT NULL,
UNIQUE(spec_dir, root_yaml)
);
CREATE TABLE IF NOT EXISTS invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
git_branch TEXT NOT NULL,
cwd TEXT NOT NULL,
command_path TEXT NOT NULL,
argv_json TEXT NOT NULL,
exit_code INTEGER NOT NULL,
spec_root_id INTEGER
);
",
)?;
ensure_invocations_spec_root_column(&conn)?;
let spec_root_id = upsert_spec_root(&conn, spec_root)?;
let ts = unix_ts();
let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
let cwd_s = cwd.to_string_lossy();
conn.execute(
"INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
ts,
branch,
cwd_s.as_ref(),
command_path,
argv_json,
exit_code,
spec_root_id
],
)?;
Ok(())
}
fn unix_ts() -> String {
use std::time::SystemTime;
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
#[derive(Debug)]
pub struct MatchOutcome<'a> {
pub chain: Vec<String>,
pub node: Option<&'a CommandNode>,
pub trailing: Vec<OsString>,
pub wants_help: bool,
}
pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
let mut chain = Vec::new();
let mut node: Option<&'a CommandNode> = None;
let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
let mut i = 0usize;
let len = args.len();
while i < len {
let raw = &args[i];
if raw == "--help" || raw == "-h" {
return MatchOutcome {
chain,
node,
trailing: args[i + 1..].to_vec(),
wants_help: true,
};
}
let key = raw.to_string_lossy();
if let Some(next) = map.get(key.as_ref()) {
chain.push(key.into_owned());
node = Some(next);
map = &next.commands;
i += 1;
continue;
}
break;
}
MatchOutcome {
chain,
node,
trailing: args[i..].to_vec(),
wants_help: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn examples_default_spec_validates() {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
load_spec(&path).unwrap();
}
#[test]
fn merge_specs_adds_and_replaces_leaves() {
let mut base = load_spec_from_str(
r"
commands:
a:
about: base
commands:
x:
about: old
exec:
argv: [echo, old]
",
None,
)
.unwrap();
let overlay = load_spec_from_str(
r"
commands:
a:
commands:
x:
about: new leaf
exec:
argv: [echo, new]
b:
about: added top
exec:
argv: [echo, b]
",
None,
)
.unwrap();
merge_specs_into(&mut base, overlay).unwrap();
base.commands["a"].commands["x"].validate("a x").unwrap();
assert_eq!(
base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
vec!["echo", "new"]
);
assert_eq!(
base.commands["b"].exec.as_ref().unwrap().argv,
vec!["echo", "b"]
);
}
#[test]
fn validate_rejects_exec_with_children() {
let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
write!(
tmp,
r"
commands:
x:
exec:
argv: [echo]
commands:
child:
about: nested
"
)
.unwrap();
let err = load_spec(tmp.path()).unwrap_err();
assert!(err.to_string().contains("cannot define both"));
}
#[test]
fn shell_inline_c_needs_argv0_detects_bash_lc() {
let argv = vec![
"bash".into(),
"-lc".into(),
"case \"$1\" in create) ;; esac".into(),
];
assert!(shell_inline_c_needs_argv0(&argv));
let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
assert!(!shell_inline_c_needs_argv0(&with_placeholder));
assert!(!shell_inline_c_needs_argv0(&[
"echo".into(),
"start".into()
]));
assert!(!shell_inline_c_needs_argv0(&[
"python3".into(),
"-c".into(),
"print(1)".into()
]));
}
#[test]
fn shell_passthrough_argv0_skips_run_leaf() {
assert_eq!(
shell_passthrough_argv0(&[
"scripts".into(),
"misc".into(),
"issue".into(),
"run".into()
]),
"issue"
);
assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
}
#[test]
fn language_exec_path_vs_inline_detection() {
assert!(ExecSpec::python_value_is_path("scripts/x.py"));
assert!(ExecSpec::python_value_is_path("X.PY"));
assert!(!ExecSpec::python_value_is_path("print(1)\n"));
assert!(!ExecSpec::python_value_is_path("import sys"));
assert!(ExecSpec::node_value_is_path("a.js"));
assert!(ExecSpec::node_value_is_path("a.mjs"));
assert!(ExecSpec::node_value_is_path("a.cjs"));
assert!(!ExecSpec::node_value_is_path("console.log(1)"));
assert!(!ExecSpec::node_value_is_path("x.ts"));
assert!(ExecSpec::bash_value_is_path("x.sh"));
assert!(ExecSpec::bash_value_is_path("x.bash"));
assert!(!ExecSpec::bash_value_is_path("echo hi"));
assert!(ExecSpec::sh_value_is_path("x.sh"));
assert!(ExecSpec::zsh_value_is_path("x.zsh"));
let bash = ExecSpec {
bash: Some("echo hi".into()),
..Default::default()
};
bash.validate("t").unwrap();
let python = ExecSpec {
python: Some("print(1)".into()),
..Default::default()
};
python.validate("t").unwrap();
let node = ExecSpec {
node: Some("console.log(1)".into()),
..Default::default()
};
node.validate("t").unwrap();
let both = ExecSpec {
python: Some("x.py".into()),
node: Some("x.js".into()),
..Default::default()
};
assert!(both.validate("t").is_err());
let text = ExecSpec {
text: Some("hello docs\n".into()),
..Default::default()
};
text.validate("t").unwrap();
let cat: ExecSpec = serde_yaml::from_str("cat: |\n printed as-is\n").unwrap();
assert_eq!(cat.literal_text(), Some("printed as-is"));
}
#[test]
fn format_help_inlines_help_child_and_hides_help_leaf() {
let spec = load_spec_from_str(
r#"
commands:
backup:
about: Backup a path
inputs:
path:
required: true
type: path
commands:
help:
about: Describe this script.
exec:
text: |
backup — copy files
Example: jan backup run --path /data
run:
about: Run the backup
exec:
argv: [echo, ok]
"#,
None,
)
.unwrap();
let node = &spec.commands["backup"];
let help = format_help(&spec, &["backup".into()], Some(node));
assert!(help.contains("backup — copy files"));
assert!(help.contains("jan backup run --path /data"));
assert!(help.contains(" run — Run the backup"));
assert!(!help.contains(" help —"));
assert!(help.contains("--path"));
let run = &node.commands["run"];
let run_help = format_help(&spec, &["backup".into(), "run".into()], Some(run));
assert!(run_help.contains("backup — copy files"));
assert!(run_help.contains("--path"));
}
#[test]
fn format_help_lists_node_aliases_and_alias_only_children() {
let spec = load_spec_from_str(
r#"
metadata:
name: jan
commands:
android:
about: android utilities
aliases:
adbt: adb-triage
android-reboot: adb reboot
commands:
dump:
about: Dump device state
exec:
argv: [echo, ok]
linux-shell:
aliases:
tulpn: netstat -tulpn
last_branch:
aliases: [lb]
commands:
run:
exec:
argv: [echo, branches]
"#,
None,
)
.unwrap();
let android = &spec.commands["android"];
let help = format_help(&spec, &["android".into()], Some(android));
assert!(help.contains("Aliases (`jan alias`):"), "{help}");
assert!(help.contains(" adbt — adb-triage"), "{help}");
assert!(help.contains(" android-reboot — adb reboot"), "{help}");
assert!(help.contains(" dump — Dump device state"), "{help}");
assert!(
help.contains(" linux-shell — shell aliases"),
"alias-only children should appear in the subcommand list: {help}"
);
assert!(
!help.contains(" tulpn —"),
"child aliases belong on the child node's help, not the parent: {help}"
);
let linux = &android.commands["linux-shell"];
let linux_help = format_help(
&spec,
&["android".into(), "linux-shell".into()],
Some(linux),
);
assert!(
linux_help.contains(" tulpn — netstat -tulpn"),
"{linux_help}"
);
let last = &spec.commands["last_branch"];
let last_help = format_help(&spec, &["last_branch".into()], Some(last));
assert!(
last_help.contains(" lb — same as `jan last_branch run`"),
"{last_help}"
);
}
#[test]
fn format_help_lists_node_config() {
let spec = load_spec_from_str(
r#"
metadata:
name: jan
commands:
config:
about: host configuration
commands:
zsh:
about: zsh fragments
config:
shell:
path: config/zsh.zsh
emacs:
config:
link:
~/.emacs.d/init.el: config/init.el
git:
config:
apply:
- [git, config, --global, alias.co, checkout]
"#,
None,
)
.unwrap();
let root = &spec.commands["config"];
let help = format_help(&spec, &["config".into()], Some(root));
assert!(
help.contains(" zsh — zsh fragments"),
"config children should be listed: {help}"
);
assert!(
help.contains(" emacs — host configuration"),
"config-only child blurb: {help}"
);
let zsh = &root.commands["zsh"];
let zsh_help = format_help(&spec, &["config".into(), "zsh".into()], Some(zsh));
assert!(
zsh_help.contains("Host configuration (`jan config`):"),
"{zsh_help}"
);
assert!(
zsh_help.contains(" shell — path: config/zsh.zsh"),
"{zsh_help}"
);
let emacs = &root.commands["emacs"];
let emacs_help = format_help(&spec, &["config".into(), "emacs".into()], Some(emacs));
assert!(
emacs_help.contains(" link — ~/.emacs.d/init.el ← path: config/init.el")
|| emacs_help.contains(" link — ~/.emacs.d/init.el ← inline"),
"{emacs_help}"
);
let git = &root.commands["git"];
let git_help = format_help(&spec, &["config".into(), "git".into()], Some(git));
assert!(
git_help.contains(" apply — 1 argv list(s) (`jan config apply`)"),
"{git_help}"
);
}
#[test]
fn gherkin_test_names() {
assert!(gherkin_test_name(
"given_a_csv_when_summarized_then_prints_shape"
));
assert!(gherkin_test_name(
"given a file when basename then prints name"
));
assert!(gherkin_test_name(
"given-a-name-when-run-then-mentions-birthday"
));
assert!(!gherkin_test_name("prints_hello"));
assert!(!gherkin_test_name("given_when_then"));
assert!(!gherkin_test_name("given_x_when_y"));
let t = CommandTest {
when: "jan hello".into(),
then: "test \"$JAN_STATUS\" -eq 0".into(),
..Default::default()
};
t.validate("hello", "given_no_args_when_run_then_ok")
.unwrap();
assert!(t.validate("hello", "not_gherkin").is_err());
}
#[test]
fn aliases_spec_deserializes_string_list_and_map() {
let spec: AliasesSpec = serde_yaml::from_str("lb").unwrap();
assert_eq!(spec.names, vec!["lb"]);
assert!(spec.shell.is_empty());
let spec: AliasesSpec = serde_yaml::from_str("[lb, lbr]").unwrap();
assert_eq!(spec.names, vec!["lb", "lbr"]);
let spec: AliasesSpec = serde_yaml::from_str("gs: git status\nlb:\ng: git\n").unwrap();
assert_eq!(spec.names, vec!["lb"]);
assert_eq!(spec.shell.get("gs").map(String::as_str), Some("git status"));
assert_eq!(spec.shell.get("g").map(String::as_str), Some("git"));
}
#[test]
fn config_spec_deserializes_shell_path_inline_link_apply() {
let spec: ConfigSpec = serde_yaml::from_str(
r#"
shell:
path: config/zsh.zsh
link:
~/.emacs.d/init.el: config/init.el
~/.config/nvim/init.vim: |
(message "nvim")
apply:
- [git, config, --global, alias.co, checkout]
deps:
ag: the_silver_searcher
fzf:
"#,
)
.unwrap();
assert_eq!(spec.shell, Some(ConfigShell::Path("config/zsh.zsh".into())));
assert_eq!(
spec.link.get("~/.emacs.d/init.el"),
Some(&ConfigLinkSource::Path("config/init.el".into()))
);
let nvim = spec.link.get("~/.config/nvim/init.vim").unwrap();
match nvim {
ConfigLinkSource::Inline(s) => assert!(s.contains("(message \"nvim\")"), "{s}"),
other => panic!("expected inline link, got {other:?}"),
}
assert_eq!(
spec.apply,
vec![vec![
"git".to_string(),
"config".to_string(),
"--global".to_string(),
"alias.co".to_string(),
"checkout".to_string()
]]
);
assert_eq!(
spec.deps.get("ag").map(String::as_str),
Some("the_silver_searcher")
);
assert_eq!(spec.deps.get("fzf").map(String::as_str), Some(""));
let inline: ConfigSpec = serde_yaml::from_str("shell: |\n setopt AUTO_CD\n").unwrap();
assert!(matches!(inline.shell, Some(ConfigShell::Inline(s)) if s.contains("AUTO_CD")));
}
#[test]
fn config_spec_rejects_link_path_that_looks_like_file_contents() {
let node = CommandNode {
config: ConfigSpec {
link: BTreeMap::from([(
"~/.emacs.d/init.el".into(),
ConfigLinkSource::Path(";;; init.el ---\n;;; Commentary:\n".into()),
)]),
..Default::default()
},
..Default::default()
};
let err = node.validate("config emacs").unwrap_err().to_string();
assert!(
err.contains("looks like file contents"),
"unexpected err: {err}"
);
}
#[test]
fn config_spec_rejects_absolute_shell_path() {
let mut node = CommandNode {
config: ConfigSpec {
shell: Some(ConfigShell::Path("/etc/zshrc".into())),
..Default::default()
},
..Default::default()
};
assert!(node.validate("x").is_err());
node.config.shell = Some(ConfigShell::Path("config/../escape.zsh".into()));
assert!(node.validate("x").is_err());
}
#[test]
fn format_help_lists_config_only_children() {
let spec = load_spec_from_str(
r#"
commands:
config:
about: host configuration
commands:
zsh:
config:
shell: |
setopt AUTO_CD
"#,
None,
)
.unwrap();
let config = &spec.commands["config"];
let help = format_help(&spec, &["config".into()], Some(config));
assert!(help.contains(" zsh — host configuration"), "{help}");
}
#[test]
fn aliases_names_require_jan_target() {
let spec = load_spec_from_str(
r"
commands:
git:
aliases: [g]
commands:
status:
exec:
argv: [echo, ok]
",
None,
);
let err = spec.unwrap_err().to_string();
assert!(err.contains("jan alias target"), "{err}");
}
#[test]
fn aliases_reject_unsafe_names() {
let spec = load_spec_from_str(
r"
commands:
leaf:
aliases:
'x;rm': echo pwn
exec:
argv: [echo, ok]
",
None,
);
let err = spec.unwrap_err().to_string();
assert!(err.contains("must match"), "{err}");
}
}
pub fn default_db_path() -> PathBuf {
if let Ok(p) = std::env::var("JAN_DB") {
return PathBuf::from(p);
}
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("jan-cli")
.join("audit.db")
}