use unilang::data::{ ErrorCode, ErrorData, OutputData };
use unilang::interpreter::ExecutionContext;
use unilang::semantic::VerifiedCommand;
use unilang::types::Value;
use crate::output::{ OutputFormat, OutputOptions, json_escape };
const RELEASES_API_URL : &str = "https://api.github.com/repos/anthropics/claude-code/releases?per_page=100";
const CACHE_TTL_SECS : u64 = 3600;
struct ReleaseInfo
{
version : String,
date : String,
summary : String,
body : String,
}
fn parse_json_string_value( json : &str, key : &str ) -> Option< String >
{
let colon_pat = format!( "\"{key}\":" );
let colon_pos = json.find( &colon_pat )? + colon_pat.len();
let rest = &json[ colon_pos.. ];
let quote = rest.find( '"' )?;
let value_start = colon_pos + quote + 1;
let content = &json[ value_start.. ];
let mut out = String::new();
let mut chars = content.chars();
let mut escaped = false;
while let Some( ch ) = chars.next()
{
if escaped
{
match ch
{
'n' => out.push( '\n' ),
'r' => out.push( '\r' ),
't' => out.push( '\t' ),
'b' => out.push( '\x08' ), 'f' => out.push( '\x0C' ), '"' => out.push( '"' ),
'\\' => out.push( '\\' ),
'u' =>
{
let hex : String = chars.by_ref().take( 4 ).collect();
if hex.len() == 4
{
if let Ok( cp ) = u32::from_str_radix( &hex, 16 )
{
if ( 0xD800..=0xDBFF ).contains( &cp )
{
let mut low_hex = String::new();
if chars.next() == Some( '\\' ) && chars.next() == Some( 'u' )
{
low_hex = chars.by_ref().take( 4 ).collect();
}
if low_hex.len() == 4
{
if let Ok( lo ) = u32::from_str_radix( &low_hex, 16 )
{
if ( 0xDC00..=0xDFFF ).contains( &lo )
{
let scalar = 0x1_0000 + ( ( cp - 0xD800 ) << 10 ) + ( lo - 0xDC00 );
if let Some( c ) = char::from_u32( scalar )
{
out.push( c );
}
}
}
}
}
else if let Some( c ) = char::from_u32( cp )
{
out.push( c );
}
}
}
}
other => out.push( other ),
}
escaped = false;
}
else if ch == '\\'
{
escaped = true;
}
else if ch == '"'
{
return Some( out );
}
else
{
out.push( ch );
}
}
None
}
fn extract_releases( json : &str ) -> Vec< ReleaseInfo >
{
let marker_spaced = "\"tag_name\": \"v";
let marker_compact = "\"tag_name\":\"v";
let ( marker, chunks ) : ( &str, Vec< &str > ) = if json.contains( marker_spaced )
{
( marker_spaced, json.split( marker_spaced ).collect() )
}
else
{
( marker_compact, json.split( marker_compact ).collect() )
};
let mut releases = Vec::new();
for chunk in chunks.iter().skip( 1 )
{
let restored = format!( "{marker}{chunk}" );
let version = parse_json_string_value( &restored, "tag_name" )
.map( | v | v.strip_prefix( 'v' ).unwrap_or( &v ).to_string() )
.unwrap_or_default();
let date = parse_json_string_value( &restored, "published_at" )
.map( | d | d.chars().take( 10 ).collect() )
.unwrap_or_default();
let body_raw = parse_json_string_value( &restored, "body" )
.unwrap_or_default();
let summary = body_raw
.lines()
.find( | l | l.starts_with( "- " ) )
.map_or_else( || "(no changelog)".to_string(), | l | l[ 2.. ].trim().to_string() );
releases.push( ReleaseInfo { version, date, summary, body : body_raw } );
}
releases
}
fn cache_is_fresh( path : &std::path::Path ) -> bool
{
std::fs::metadata( path )
.and_then( | m | m.modified() )
.ok()
.and_then( | mtime | std::time::SystemTime::now().duration_since( mtime ).ok() )
.is_some_and( | elapsed | elapsed.as_secs() < CACHE_TTL_SECS )
}
fn fetch_releases_json( base : &std::path::Path ) -> Result< String, ErrorData >
{
let cache_dir = base.join( ".transient" );
let cache_path = cache_dir.join( "version_history_cache.json" );
if cache_is_fresh( &cache_path )
{
if let Ok( cached ) = std::fs::read_to_string( &cache_path )
{
if !cached.is_empty()
{
return Ok( cached );
}
}
}
let output = std::process::Command::new( "curl" )
.args( [ "-fsSL", RELEASES_API_URL ] )
.output()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "curl not found or fetch failed: {e}" ) ) )?;
if !output.status.success()
{
return Err( ErrorData::new( ErrorCode::InternalError, "failed to fetch release history".to_string() ) );
}
let response = String::from_utf8_lossy( &output.stdout ).to_string();
if response.trim().is_empty()
{
return Err( ErrorData::new( ErrorCode::InternalError, "empty response from GitHub API".to_string() ) );
}
let _ = std::fs::create_dir_all( &cache_dir );
let _ = std::fs::write( &cache_path, &response );
Ok( response )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn version_history_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let count = match cmd.arguments.get( "count" )
{
Some( Value::Integer( n ) ) => usize::try_from( *n ).unwrap_or( 10 ),
_ => 10,
};
if count == 0
{
let content = match opts.format
{
OutputFormat::Json => "[]\n".to_string(),
OutputFormat::Text => String::new(),
};
return Ok( OutputData::new( content, "text" ) );
}
let paths = super::require_claude_paths()?;
let json = fetch_releases_json( paths.base() )?;
let mut releases = extract_releases( &json );
releases.truncate( count );
let content = match ( opts.format, opts.verbosity )
{
( OutputFormat::Json, _ ) =>
{
if releases.is_empty()
{
"[]\n".to_string()
}
else
{
let entries : Vec< String > = releases.iter().map( | r |
{
let v = json_escape( &r.version );
let d = json_escape( &r.date );
let s = json_escape( &r.summary );
format!( " {{\"version\":\"{v}\",\"date\":\"{d}\",\"summary\":\"{s}\"}}" )
} ).collect();
format!( "[\n{}\n]\n", entries.join( ",\n" ) )
}
}
( OutputFormat::Text, 0 ) =>
{
if releases.is_empty()
{
String::new()
}
else
{
let lines : Vec< String > = releases.iter()
.map( | r | format!( "{} {}", r.version, r.date ) )
.collect();
format!( "{}\n", lines.join( "\n" ) )
}
}
( OutputFormat::Text, 1 ) =>
{
if releases.is_empty()
{
String::new()
}
else
{
let lines : Vec< String > = releases.iter()
.map( | r | format!( "{} {} {}", r.version, r.date, r.summary ) )
.collect();
format!( "{}\n", lines.join( "\n" ) )
}
}
( OutputFormat::Text, _ ) =>
{
if releases.is_empty()
{
String::new()
}
else
{
let blocks : Vec< String > = releases.iter()
.map( | r |
{
let header = format!( "## {} ({})", r.version, r.date );
if r.body.is_empty()
{
header
}
else
{
format!( "{header}\n\n{}", r.body )
}
} )
.collect();
format!( "{}\n", blocks.join( "\n\n" ) )
}
}
};
Ok( OutputData::new( content, "text" ) )
}