package engine import ( "crypto/sha256" "encoding/hex" "fmt" "io" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/h2non/filetype" "github.com/alexandrev/homeflow/internal/config" "github.com/alexandrev/homeflow/internal/state" ) // ActionContext is passed to each action in the pipeline. // Actions that rename or move the file must update CurrentPath. type ActionContext struct { CurrentPath string OriginalPath string RuleName string Vars map[string]string // pre-computed template variables Services map[string]config.ServiceConfig State *state.DB } // RenderTemplate replaces {key} and {key:format} placeholders. // Supported keys: filename, stem, ext, hash8, size_kb, seq, date, mtime. func RenderTemplate(tmpl string, ctx *ActionContext) string { var sb strings.Builder i := 0 for i < len(tmpl) { if tmpl[i] == '{' { j := strings.Index(tmpl[i:], "}") if j == -1 { sb.WriteByte(tmpl[i]) i++ continue } token := tmpl[i+1 : i+j] i += j + 1 sb.WriteString(resolveToken(token, ctx)) } else { sb.WriteByte(tmpl[i]) i++ } } return sb.String() } func resolveToken(token string, ctx *ActionContext) string { key, format, hasFormat := strings.Cut(token, ":") switch key { case "filename": return filepath.Base(ctx.CurrentPath) case "stem": base := filepath.Base(ctx.CurrentPath) ext := filepath.Ext(base) return strings.TrimSuffix(base, ext) case "ext": return filepath.Ext(ctx.CurrentPath) case "hash8": if h, ok := ctx.Vars["hash8"]; ok { return h } return "" case "size_kb": if s, ok := ctx.Vars["size_kb"]; ok { return s } return "0" case "seq": if s, ok := ctx.Vars["seq"]; ok { return s } return "0" case "date": if hasFormat { return time.Now().Format(format) } return time.Now().Format("2006-01-02") case "mtime": if ts, ok := ctx.Vars["mtime"]; ok { if hasFormat { t, err := time.Parse(time.RFC3339, ts) if err == nil { return t.Format(format) } } return ts } return "" } // fall back to Vars map if v, ok := ctx.Vars[key]; ok { return v } return "{" + token + "}" } // MatchFile checks if a file matches a rule's conditions. func MatchFile(path string, rule *config.RuleConfig) (bool, error) { info, err := os.Stat(path) if err != nil { return false, err } m := &rule.Match // Extension check if len(m.Extensions) > 0 { ext := strings.ToLower(filepath.Ext(path)) found := false for _, e := range m.Extensions { if strings.ToLower(e) == ext { found = true break } } if !found { return false, nil } } // Name pattern (glob) if m.NamePattern != "" { matched, err := filepath.Match(m.NamePattern, filepath.Base(path)) if err != nil { return false, fmt.Errorf("invalid name_pattern: %w", err) } if !matched { return false, nil } } // Name regex if m.NameRegex != "" { re, err := regexp.Compile(m.NameRegex) if err != nil { return false, fmt.Errorf("invalid name_regex: %w", err) } if !re.MatchString(filepath.Base(path)) { return false, nil } } // Size checks sizeKB := info.Size() / 1024 if m.MinSizeKB > 0 && sizeKB < m.MinSizeKB { return false, nil } if m.MaxSizeKB > 0 && sizeKB > m.MaxSizeKB { return false, nil } // Age checks ageSecs := int64(time.Since(info.ModTime()).Seconds()) if m.AgeSecsMin > 0 && ageSecs < m.AgeSecsMin { return false, nil } if m.AgeSecsMax > 0 && ageSecs > m.AgeSecsMax { return false, nil } // MIME type check (uses magic bytes — relatively expensive, do last) if len(m.MimeTypes) > 0 { mime, err := detectMIME(path) if err != nil { return false, err } found := false for _, mt := range m.MimeTypes { if strings.EqualFold(mt, mime) { found = true break } } if !found { return false, nil } } return true, nil } func detectMIME(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() buf := make([]byte, 261) n, err := f.Read(buf) if err != nil && err != io.EOF { return "", err } kind, _ := filetype.Match(buf[:n]) if kind == filetype.Unknown { return "application/octet-stream", nil } return kind.MIME.Value, nil } // BuildContext creates an ActionContext for a matched file + rule. func BuildContext(path string, rule *config.RuleConfig, services map[string]config.ServiceConfig, st *state.DB) (*ActionContext, error) { info, err := os.Stat(path) if err != nil { return nil, err } vars := map[string]string{ "mtime": info.ModTime().UTC().Format(time.RFC3339), "size_kb": strconv.FormatInt(info.Size()/1024, 10), } // Compute hash8 lazily by doing it once here if h, err := fileHash8(path); err == nil { vars["hash8"] = h } // Get seq for this rule var seqStr string if st != nil { if seq, err := st.NextSeq(rule.Name); err == nil { seqStr = strconv.FormatInt(seq, 10) } } vars["seq"] = seqStr return &ActionContext{ CurrentPath: path, OriginalPath: path, RuleName: rule.Name, Vars: vars, Services: services, State: st, }, nil } func fileHash8(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil))[:8], nil } // FileSHA256 returns the full hex SHA256 of a file (used for dedup in state). func FileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil }